Remove old frontend

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-06-13 13:38:48 -07:00
parent 2adfe50054
commit d6ba8e1072
646 changed files with 671 additions and 78623 deletions

View File

@@ -1,65 +0,0 @@
import * as React from "react";
import { Suspense, useEffect } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { NavUser } from "@/components/NavUser";
import { Sidebar } from "@/components/ui/sidebar";
import type { AppSidebarQuery as AppSidebarQueryType } from "./__generated__/AppSidebarQuery.graphql";
import { OrganizationSwitcher } from "@/components/OrganizationSwitcher";
import { AppSidebarShell } from "./AppSidebarShell";
import { AppSidebarSkeleton } from "./AppSidebarSkeleton";
import { NavMain } from "./NavMain";
const AppSidebarQuery = graphql`
query AppSidebarQuery {
viewer {
id
...OrganizationSwitcher_organizations
...NavUser_viewer
}
}
`;
function AppSidebarContent({
queryRef,
...props
}: React.ComponentProps<typeof Sidebar> & {
queryRef: PreloadedQuery<AppSidebarQueryType>;
}) {
const data = usePreloadedQuery(AppSidebarQuery, queryRef);
return (
<AppSidebarShell
organizationSwitcher={
<OrganizationSwitcher organizations={data.viewer} />
}
navMain={<NavMain />}
navUser={<NavUser viewer={data.viewer} />}
{...props}
/>
);
}
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
const [queryRef, loadQuery] =
useQueryLoader<AppSidebarQueryType>(AppSidebarQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <AppSidebarSkeleton {...props} />;
}
return (
<Suspense fallback={<AppSidebarSkeleton {...props} />}>
<AppSidebarContent queryRef={queryRef} {...props} />
</Suspense>
);
}

View File

@@ -1,32 +0,0 @@
import { NavSecondary } from "./NavSecondary";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "./ui/sidebar";
import { ReactNode } from "react";
interface AppSidebarShellProps extends React.ComponentProps<typeof Sidebar> {
navUser: ReactNode;
navMain: ReactNode;
organizationSwitcher: ReactNode;
}
export function AppSidebarShell({
navUser,
navMain,
organizationSwitcher,
...props
}: AppSidebarShellProps) {
return (
<Sidebar variant="sidebar" {...props}>
<SidebarHeader>{organizationSwitcher}</SidebarHeader>
<SidebarContent>
{navMain}
<NavSecondary className="mt-auto" />
</SidebarContent>
<SidebarFooter>{navUser}</SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,18 +0,0 @@
import { AppSidebarShell } from "./AppSidebarShell";
import { NavMainSkeleton } from "./NavMainSkeleton";
import { NavUserSkeleton } from "./NavUserSkeleton";
import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
import { Sidebar } from "./ui/sidebar";
export function AppSidebarSkeleton(
props: React.ComponentProps<typeof Sidebar>,
) {
return (
<AppSidebarShell
organizationSwitcher={<OrganizationSwitcherSkeleton />}
navMain={<NavMainSkeleton />}
navUser={<NavUserSkeleton />}
{...props}
/>
);
}

View File

@@ -1,284 +0,0 @@
.document-editor-container {
border-radius: 0.5rem;
position: relative;
line-height: 1.5;
font-weight: 400;
margin-top: 0.5rem;
border: 1px solid var(--solid-b);
display: block;
width: 100%;
}
.document-editor {
background: var(--bg-invert-bg);
position: relative;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
width: 100%;
}
.document-editor-inner {
background: #fff;
position: relative;
border-radius: 0 0 0.5rem 0.5rem;
flex: 1;
min-height: 250px;
}
.document-editor-input {
min-height: 250px;
max-height: 500px;
resize: none;
font-size: 15px;
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
overflow-y: auto;
width: 100%;
display: block;
color: #000;
}
.document-editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 10px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.document-editor-paragraph {
margin: 0 0 15px 0;
position: relative;
}
.document-editor .toolbar {
display: flex;
flex-wrap: wrap;
padding: 8px;
border-bottom: 1px solid hsl(var(--solid-b));
background-color: hsl(var(--invert-bg));
border-radius: 0.5rem 0.5rem 0 0;
}
.document-editor .toolbar button {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 8px;
cursor: pointer;
vertical-align: middle;
align-items: center;
justify-content: center;
}
.document-editor .toolbar button:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.document-editor .toolbar button.active {
background-color: rgba(0, 0, 0, 0.1);
}
.document-editor .toolbar .divider {
width: 1px;
background-color: hsl(var(--solid-b));
margin: 0 8px;
height: 24px;
}
/* Typography */
.editor-heading-h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 12px 0;
}
.editor-heading-h2 {
font-size: 20px;
font-weight: 700;
margin: 10px 0;
}
.editor-heading-h3 {
font-size: 18px;
font-weight: 600;
margin: 8px 0;
}
.editor-heading-h4 {
font-size: 16px;
font-weight: 600;
margin: 8px 0;
}
.editor-heading-h5 {
font-size: 14px;
font-weight: 600;
margin: 8px 0;
}
.editor-quote {
margin: 0;
margin-left: 20px;
padding-left: 16px;
border-left: 4px solid hsl(var(--solid-b));
color: hsl(var(--tertiary));
}
.editor-list-ol {
padding: 0;
margin: 0 0 0 16px;
list-style-type: decimal;
display: block;
counter-reset: li;
}
.editor-list-ul {
padding: 0;
margin: 0 0 0 16px;
list-style-type: disc;
display: block;
}
.editor-listitem {
margin: 8px 0 0 0;
display: list-item;
position: relative;
padding-left: 8px;
}
/* Ensure list items display consistently across browsers */
li.editor-listitem::marker {
font-weight: normal;
color: inherit;
}
/* Specifically for ordered lists */
ol.editor-list-ol > .editor-listitem {
list-style-type: decimal;
list-style-position: outside;
}
/* For nested ordered lists */
ol.editor-list-ol ol.editor-list-ol > .editor-listitem {
list-style-type: lower-alpha;
}
.editor-listitem--checked,
.editor-listitem--unchecked {
position: relative;
margin-left: 0;
padding-left: 24px;
list-style-type: none;
}
.editor-listitem--checked:before,
.editor-listitem--unchecked:before {
content: '';
width: 16px;
height: 16px;
position: absolute;
left: 0;
top: 2px;
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
}
.editor-listitem--checked:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>');
}
.editor-listitem--unchecked:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>');
}
.editor-nested-listitem {
list-style-type: none;
}
/* Text formatting */
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-underlineStrikethrough {
text-decoration: underline line-through;
}
.editor-text-code {
background-color: hsl(var(--invert-bg));
padding: 1px 4px;
border-radius: 4px;
font-family: monospace;
}
.editor-link {
color: hsl(var(--primary));
text-decoration: underline;
}
.editor-code {
background-color: hsl(var(--invert-bg));
font-family: monospace;
display: block;
padding: 8px 16px;
line-height: 1.6;
font-size: 13px;
border-radius: 0.5rem;
margin: 8px 0;
}
/* Code highlight */
.editor-tokenComment {
color: slategray;
}
.editor-tokenPunctuation {
color: #999;
}
.editor-tokenProperty {
color: #905;
}
.editor-tokenSelector {
color: #690;
}
.editor-tokenOperator {
color: #9a6e3a;
}
.editor-tokenAttr {
color: #07a;
}
.editor-tokenVariable {
color: #e90;
}
.editor-tokenFunction {
color: #dd4a68;
}

View File

@@ -1,66 +0,0 @@
import { HelpCircle } from "lucide-react";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { Input } from "./ui/input";
import { cn } from "@/lib/utils";
export function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
multiline = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
multiline?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={label} className="text-sm font-medium">
{label}
{required && <span className="text-red-500">*</span>}
</Label>
{helpText && (
<div className="relative flex items-center">
<HelpCircle className="h-4 w-4 text-tertiary" />
<span className="sr-only">{helpText}</span>
</div>
)}
</div>
{multiline ? (
<Textarea
id={label}
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange(e.target.value)
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500",
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
/>
) : (
<Input
id={label}
type={type}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
className={cn("w-full", required && !value && "border-red-500")}
placeholder={`Enter ${label.toLowerCase()}`}
/>
)}
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { ErrorPage } from "@/pages/ErrorPage";
import { UnAuthenticatedError } from "@/RelayEnvironment";
import { Navigate } from "react-router";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
isUnAuthenticated: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, isUnAuthenticated: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
if (error instanceof UnAuthenticatedError) {
return { hasError: true, error, isUnAuthenticated: true };
}
return { hasError: true, error, isUnAuthenticated: false };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error);
console.error("Error info:", info.componentStack);
}
render(): ReactNode {
if (this.state.isUnAuthenticated) {
return <Navigate to="/login" replace state={{ authRequired: true }} />;
}
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return <ErrorPage error={this.state.error} />;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,15 @@
import { availableFrameworks } from "@probo/helpers";
import { Avatar } from "@probo/ui";
const availableLogos = new Map(
availableFrameworks.map((framework) => [framework.name, framework.logo])
);
export function FrameworkLogo({ name }: { name: string }) {
const logo = availableLogos.get(name);
return logo ? (
<img src={logo} alt="" className="size-12" />
) : (
<Avatar name={name} size="l" className="size-12" />
);
}

View File

@@ -1,210 +0,0 @@
"use client";
import {
ChevronRight,
SquareCheck,
Inbox,
Store,
type LucideIcon,
Flame,
BookOpen,
FileText,
Settings,
Users,
Box,
Database
} from "lucide-react";
import { Link, useLocation, useParams } from "react-router";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
interface NavItem {
title: string;
url?: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
icon: LucideIcon;
}[];
}
export function NavMain() {
const location = useLocation();
const { organizationId } = useParams();
const items: NavItem[] = getNavItems(organizationId);
if (!organizationId) {
return null;
}
const isItemActive = (item: { url?: string; items?: { url: string }[] }) => {
if (item.url && location.pathname.startsWith(item.url)) {
return true;
}
if (item.items?.length) {
return item.items.some((subItem) =>
location.pathname.startsWith(subItem.url),
);
}
return false;
};
return (
<SidebarGroup>
<SidebarMenu>
{items.map((item) => {
const active = isItemActive(item) || item.isActive;
return (
<Collapsible key={item.title} asChild defaultOpen={active}>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
data-active={active ? "true" : undefined}
>
<Link to={item.url ?? "#"}>
<item.icon
className={active ? "text-lime-9" : "text-lime-6"}
/>
<span className="font-medium">{item.title}</span>
</Link>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRight />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => {
const subItemActive = location.pathname.startsWith(
subItem.url,
);
return (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
data-active={subItemActive ? "true" : undefined}
>
<Link to={subItem.url}>
<subItem.icon
className={
subItemActive
? "text-lime-9"
: "text-lime-6"
}
/>
<span className="font-medium">
{subItem.title}
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
);
}
function getNavItems(organizationId?: string): NavItem[] {
return [
{
title: "Tasks",
icon: Inbox,
url: organizationId
? `/organizations/${organizationId}/tasks`
: undefined,
},
{
title: "Measures",
icon: SquareCheck,
url: organizationId
? `/organizations/${organizationId}/measures`
: undefined,
},
{
title: "Risks",
icon: Flame,
url: organizationId
? `/organizations/${organizationId}/risks`
: undefined,
},
{
title: "Frameworks",
url: organizationId
? `/organizations/${organizationId}/frameworks`
: undefined,
icon: BookOpen,
},
{
title: "People",
url: `/organizations/${organizationId}/people`,
icon: Users,
},
{
title: "Vendors",
url: `/organizations/${organizationId}/vendors`,
icon: Store,
},
{
title: "Documents",
url: organizationId
? `/organizations/${organizationId}/documents`
: undefined,
icon: FileText,
},
{
title: "Assets",
url: organizationId
? `/organizations/${organizationId}/assets`
: undefined,
icon: Box,
},
{
title: "Data",
url: organizationId
? `/organizations/${organizationId}/data`
: undefined,
icon: Database,
},
{
title: "Settings",
url: organizationId
? `/organizations/${organizationId}/settings`
: undefined,
icon: Settings,
},
];
}

View File

@@ -1,55 +0,0 @@
"use client";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavMainSkeleton() {
return (
<SidebarGroup>
<SidebarGroupLabel className="pl-3">
<div className="py-0.5">
<div className="h-3 w-24 rounded-sm bg-subtle-bg animate-pulse" />
</div>
</SidebarGroupLabel>
<SidebarMenu className="space-y-1.5">
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-5" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@@ -1,60 +0,0 @@
import * as React from "react";
import { LifeBuoy, Send } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
const navItems = [
{
title: "Support",
url: "mailto:support@getprobo.com",
icon: LifeBuoy,
},
{
title: "Feedback",
url: "#",
icon: Send,
},
];
export function NavSecondary(
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>,
) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild={item.url !== "#"}
size="sm"
tooltip={item.title}
className={
item.url === "#" ? "opacity-50 cursor-not-allowed" : ""
}
>
{item.url !== "#" ? (
<a href={item.url}>
<item.icon />
<span className="font-medium">{item.title}</span>
</a>
) : (
<>
<item.icon />
<span className="font-medium">{item.title}</span>
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,100 +0,0 @@
"use client";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { graphql, useFragment } from "react-relay";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { buildEndpoint } from "@/utils";
export const navUserFragment = graphql`
fragment NavUser_viewer on Viewer {
user {
id
fullName
email
}
}
`;
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer).user;
const handleLogout = async () => {
fetch(buildEndpoint("/api/console/v1/auth/logout"), {
method: "DELETE",
credentials: "include",
}).then(() => {
window.location.href = "https://www.getprobo.com";
});
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg" variant="outline">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-highlight-bg">
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentUser.fullName}
</span>
<span className="truncate text-xs text-secondary/70">
{currentUser.email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 bg-highlight-bg">
<AvatarFallback>
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentUser.fullName}
</span>
<span className="truncate text-xs">{currentUser.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,29 +0,0 @@
"use client";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavUserSkeleton() {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive
disabled
size="lg"
className="data-[state=open]:bg-accent data-[state=open]:text-accent animate-pulse"
>
<div className="bg-subtle-bg size-9 rounded-full animate-pulse" />
<div className="flex-1 space-y-[3px] animate-pulse">
<div className="h-3.5 w-20 rounded-sm bg-subtle-bg" />
<div className="h-3.5 w-30 rounded-sm bg-subtle-bg" />
</div>
<div className="ml-auto size-4 rounded-lg bg-subtle-bg" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,180 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronsUpDown, Plus } from "lucide-react";
import { graphql, useFragment } from "react-relay";
import { Link, useNavigate, useParams } from "react-router";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
OrganizationSwitcher_organizations$key,
OrganizationSwitcher_organizations$data,
} from "./__generated__/OrganizationSwitcher_organizations.graphql";
import { cn } from "@/lib/utils";
export const organizationSwitcherFragment = graphql`
fragment OrganizationSwitcher_organizations on Viewer {
organizations(first: 25)
@connection(key: "OrganizationSwitcher_organizations") {
__id
edges {
node {
id
name
logoUrl
}
}
}
}
`;
type Organization =
OrganizationSwitcher_organizations$data["organizations"]["edges"][0]["node"];
const LogoComponent = ({
org,
className,
}: {
org: Organization;
className?: string;
}) => {
if (org.logoUrl) {
return (
<img
src={org.logoUrl}
alt={org.name}
className={cn("rounded-md", className)}
/>
);
}
return org.name.substring(0, 2).toUpperCase();
};
export function OrganizationSwitcher({
organizations,
}: {
organizations: OrganizationSwitcher_organizations$key;
}) {
const { isMobile } = useSidebar();
const data = useFragment(organizationSwitcherFragment, organizations);
const navigate = useNavigate();
const { organizationId } = useParams();
const [currentOrganization, setCurrentOrganization] =
useState<Organization | null>(null);
const hasOrganizations =
data.organizations && data.organizations.edges.length > 0;
useEffect(() => {
if (hasOrganizations) {
const org = data.organizations.edges.find(
(edge) => edge.node.id === organizationId,
);
if (org) {
setCurrentOrganization(org.node);
}
}
}, [data.organizations, organizationId, hasOrganizations]);
const handleOrganizationSwitch = (org: Organization) => {
navigate(`/organizations/${org.id}`);
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
variant="ghost"
className={cn(
"gap-2.5",
!currentOrganization && "border border-dashed",
)}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-highlight-bg">
{currentOrganization ? (
<LogoComponent org={currentOrganization} className="size-8" />
) : (
<div className="size-8 bg-highlight-bg rounded-sm" />
)}
</div>
<div className="grid text-left leading-tight text-primary">
{currentOrganization ? (
<>
<span className="truncate font-medium text-lg leading-5">
{currentOrganization.name}
</span>
</>
) : (
<span className="truncate text-secondary font-medium">
Select Organization
</span>
)}
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-tertiary">
Organizations
</DropdownMenuLabel>
{hasOrganizations &&
data.organizations.edges.map((edge, index) => (
<DropdownMenuItem
key={edge.node.id}
onClick={() => handleOrganizationSwitch(edge.node)}
className={`gap-2 p-2 ${
edge.node.id === organizationId ? "bg-subtle-bg" : ""
}`}
>
<div className="flex size-6 items-center justify-center rounded-sm bg-highlight-bg">
<LogoComponent
org={edge.node}
className="size-4 shrink-0"
/>
</div>
{edge.node.name}
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="/organizations/new"
className="gap-2 p-2 cursor-pointer"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-secondary-bg">
<Plus className="size-4" />
</div>
<div className="font-medium text-tertiary">
Add organization
</div>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,31 +0,0 @@
"use client";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function OrganizationSwitcherSkeleton() {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-accent-bg data-[state=open]:text-accent gap-2.5"
>
<div className="size-8 items-center justify-center rounded-md bg-subtle-bg animate-pulse" />
<div className="flex flex-col items-start">
<div className="py-[1px]">
<div className="h-4.5 animate-pulse w-16 rounded-sm bg-subtle-bg" />
</div>
<div className="py-0.5">
<div className="h-3 animate-pulse w-8 rounded-sm bg-subtle-bg" />
</div>
</div>
<div className="ml-auto size-4 rounded-lg bg-subtle-bg animate-pulse" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,26 +0,0 @@
import { FC, ReactNode } from "react";
import { Helmet } from "react-helmet-async";
export interface PageContainerProps {
children: ReactNode;
title: string;
}
export const PageContainer: FC<PageContainerProps> = ({ children, title }) => {
return (
<>
<Helmet>{`${title} - Probo Console`}</Helmet>
<div className="container">{children}</div>
</>
);
};
export const PageContainerSkeleton: FC<{
title?: string;
children: ReactNode;
}> = ({ children, title }) =>
title ? (
<PageContainer title={title}>{children}</PageContainer>
) : (
<div className="container">{children}</div>
);

View File

@@ -0,0 +1,60 @@
import { useLocation, useRouteError } from "react-router";
import { IconPageCross } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useEffect, useRef } from "react";
const classNames = {
wrapper: "py-10 text-center space-y-2 ",
title: "text-2xl flex gap-2 font-semibold items-center justify-center",
description: "text-base text-txt-tertiary",
detail:
"text-sm text-txt-tertiary font-mono text-start border border-border-low p-2 rounded bg-level-1 mt-2",
};
type Props = {
resetErrorBoundary?: () => void;
error?: string;
};
export function PageError({ resetErrorBoundary, error: propsError }: Props) {
const error = useRouteError() ?? propsError;
const { __ } = useTranslate();
const location = useLocation();
const baseLocation = useRef(location);
// Reset error boundary on page change
useEffect(() => {
if (
location.pathname !== baseLocation.current.pathname &&
resetErrorBoundary
) {
resetErrorBoundary();
}
}, [location, resetErrorBoundary]);
if (!error) {
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>
<IconPageCross size={26} />
{__("Page not found")}
</h1>
<p className={classNames.description}>
{__("The page you are looking for does not exist")}
</p>
</div>
);
}
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>{__("Unexpected error :(")}</h1>
<details>
<summary className={classNames.description}>
{__("Something went wrong")}
</summary>
<p className={classNames.detail}>{error.toString()}</p>
</details>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { cn } from "@/lib/utils";
import { FC, ReactNode } from "react";
export interface PageHeaderProps {
title: ReactNode;
actions?: ReactNode;
description?: ReactNode;
}
export function PageHeaderShell({
className,
title,
actions,
description,
}: PageHeaderProps & { className?: string }) {
return (
<div className={cn("space-y-6", className)}>
{actions ? (
<div className="flex items-center justify-between">
{title}
{actions}
</div>
) : (
title
)}
{description}
</div>
);
}
export function PageHeader({
className,
title,
actions,
description,
}: PageHeaderProps & { className?: string }) {
return (
<PageHeaderShell
className={className}
title={<PageHeading>{title}</PageHeading>}
description={<PageDescription>{description}</PageDescription>}
actions={actions}
/>
);
}
export function PageHeading({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return <h1 className={cn("text-3xl font-medium", className)}>{children}</h1>;
}
const PageHeadingSkeleton: FC = () => {
return (
<div className="py-[3px] w-2/5">
<div className="bg-subtle-bg animate rounded-lg h-7.5" />
</div>
);
};
export function PageDescription({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("text-md text-tertiary text-left", className)}>
{children}
</div>
);
}
export function PageDescriptionSkeleton() {
return (
<div className="py-[5px]">
<div className="bg-subtle-bg animate rounded-md w-100 h-4.5" />
</div>
);
}
export const PageHeaderSkeleton: FC<{
className?: string;
title?: string;
actions?: ReactNode;
description?: ReactNode;
withDescription?: boolean;
}> = ({ className, title, actions, description, withDescription = false }) => {
return (
<PageHeaderShell
className={className}
title={
title ? <PageHeading>{title}</PageHeading> : <PageHeadingSkeleton />
}
description={
description ? (
<PageDescription>{description}</PageDescription>
) : withDescription ? (
<PageDescriptionSkeleton />
) : null
}
actions={actions}
/>
);
};

View File

@@ -1,47 +0,0 @@
import { FC, ReactNode } from "react";
import {
PageContainer,
PageContainerProps,
PageContainerSkeleton,
} from "./PageContainer";
import { PageHeader, PageHeaderProps, PageHeaderSkeleton } from "./PageHeader";
export const PageTemplate: FC<PageContainerProps & PageHeaderProps> = ({
actions,
title,
description,
children,
}) => {
return (
<PageContainer title={title}>
<PageHeader
className="mb-12"
title={title}
description={description}
actions={actions}
/>
{children}
</PageContainer>
);
};
export const PageTemplateSkeleton: FC<{
title?: string;
description?: ReactNode;
withDescription?: boolean;
actions?: ReactNode;
children: ReactNode;
}> = ({ actions, children, description, withDescription = false, title }) => {
return (
<PageContainerSkeleton title={title}>
<PageHeaderSkeleton
className="mb-17"
title={title}
description={description}
withDescription={withDescription}
actions={actions}
/>
{children}
</PageContainerSkeleton>
);
};

View File

@@ -1,74 +0,0 @@
import { useState, useEffect } from "react";
import { graphql, useFragment } from "react-relay";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PeopleSelector_organization$key } from "./__generated__/PeopleSelector_organization.graphql";
const peopleSelectorFragment = graphql`
fragment PeopleSelector_organization on Organization {
id
peoples(first: 100, orderBy: { direction: ASC, field: FULL_NAME })
@connection(key: "PeopleSelector_organization_peoples") {
edges {
node {
id
fullName
primaryEmailAddress
}
}
}
}
`;
interface PeopleSelectorProps {
organizationRef: PeopleSelector_organization$key;
selectedPersonId: string | null;
onSelect: (personId: string) => void;
placeholder?: string;
required?: boolean;
}
export default function PeopleSelector({
organizationRef,
selectedPersonId,
onSelect,
placeholder = "Select a person",
required = false,
}: PeopleSelectorProps) {
const organization = useFragment(peopleSelectorFragment, organizationRef);
const [value, setValue] = useState<string>(selectedPersonId || "");
useEffect(() => {
if (selectedPersonId) {
setValue(selectedPersonId);
}
}, [selectedPersonId]);
const handleValueChange = (newValue: string) => {
setValue(newValue);
onSelect(newValue);
};
return (
<Select value={value} onValueChange={handleValueChange} required={required}>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{organization.peoples?.edges?.map(
(edge) =>
edge?.node && (
<SelectItem key={edge.node.id} value={edge.node.id}>
{edge.node.fullName} ({edge.node.primaryEmailAddress})
</SelectItem>
),
)}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,80 @@
import { IconChevronTriangleDownSmall, Table, Th } from "@probo/ui";
import clsx from "clsx";
import {
createContext,
startTransition,
useContext,
useState,
type ComponentProps,
} from "react";
type Order = {
direction: string;
field: string;
};
const SortableContext = createContext({
order: {
direction: "DESC",
field: "CREATED_AT",
},
onOrderChange: (() => {}) as (order: Order) => void,
});
const defaultOrder = {
direction: "DESC",
field: "CREATED_AT",
} as Order;
export function SortableTable({
refetch,
...props
}: ComponentProps<typeof Table> & {
refetch: (o: { order: Order }) => void;
}) {
const [order, setOrder] = useState(defaultOrder);
const onOrderChange = (o: Order) => {
startTransition(() => {
setOrder(o);
refetch({ order: o });
});
};
return (
<SortableContext value={{ order, onOrderChange }}>
<Table {...props} />
</SortableContext>
);
}
export function SortableTh({
children,
field,
...props
}: ComponentProps<typeof Th> & { field: string }) {
const { order, onOrderChange } = useContext(SortableContext);
const isCurrentField = order.field === field;
const isDesc = order.direction === "DESC";
const changeOrder = () => {
onOrderChange({
direction: isDesc && isCurrentField ? "ASC" : "DESC",
field,
});
};
return (
<Th {...props}>
<button
className="flex items-center cursor-pointer hover:text-txt-primary"
onClick={changeOrder}
>
{children}
<IconChevronTriangleDownSmall
size={16}
className={clsx(
isCurrentField && "text-txt-primary",
isCurrentField && !isDesc && "rotate-180"
)}
/>
</button>
</Th>
);
}

View File

@@ -1,48 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { ErrorPage } from "@/pages/ErrorPage";
import { UnAuthenticatedError } from "@/RelayEnvironment";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class VisitorErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
if (error instanceof UnAuthenticatedError) {
return { hasError: false, error: undefined };
}
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error);
console.error("Error info:", info.componentStack);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return <ErrorPage error={this.state.error} />;
}
return this.props.children;
}
}
export default VisitorErrorBoundary;

View File

@@ -1,240 +0,0 @@
/**
* @generated SignedSource<<72d01b98680bc82c2d5bdc8c2fa2d49d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type AppSidebarQuery$variables = Record<PropertyKey, never>;
export type AppSidebarQuery$data = {
readonly viewer: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"NavUser_viewer" | "OrganizationSwitcher_organizations">;
};
};
export type AppSidebarQuery = {
response: AppSidebarQuery$data;
variables: AppSidebarQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = [
{
"kind": "Literal",
"name": "first",
"value": 25
}
];
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "AppSidebarQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "OrganizationSwitcher_organizations"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "NavUser_viewer"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "AppSidebarQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "organizations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": "organizations(first:25)"
},
{
"alias": null,
"args": (v1/*: any*/),
"filters": null,
"handle": "connection",
"key": "OrganizationSwitcher_organizations",
"kind": "LinkedHandle",
"name": "organizations"
},
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "51c72d5d6b9e1d4e5e7f45e7280a0534",
"id": null,
"metadata": {},
"name": "AppSidebarQuery",
"operationKind": "query",
"text": "query AppSidebarQuery {\n viewer {\n id\n ...OrganizationSwitcher_organizations\n ...NavUser_viewer\n }\n}\n\nfragment NavUser_viewer on Viewer {\n user {\n id\n fullName\n email\n }\n}\n\nfragment OrganizationSwitcher_organizations on Viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
}
};
})();
(node as any).hash = "ebe1f481a1454380e017fb976b8bb8a5";
export default node;

View File

@@ -1,71 +0,0 @@
/**
* @generated SignedSource<<0d5ada0f912fcf3a8f2f3328b0943b28>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NavUser_viewer$data = {
readonly user: {
readonly email: string;
readonly fullName: string;
readonly id: string;
};
readonly " $fragmentType": "NavUser_viewer";
};
export type NavUser_viewer$key = {
readonly " $data"?: NavUser_viewer$data;
readonly " $fragmentSpreads": FragmentRefs<"NavUser_viewer">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "NavUser_viewer",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Viewer",
"abstractKey": null
};
(node as any).hash = "05e26e238976738b4cff26f37c5f29e7";
export default node;

View File

@@ -1,160 +0,0 @@
/**
* @generated SignedSource<<152991faff70c7ef6c4751fac48090be>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type OrganizationSwitcher_organizations$data = {
readonly organizations: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
};
}>;
};
readonly " $fragmentType": "OrganizationSwitcher_organizations";
};
export type OrganizationSwitcher_organizations$key = {
readonly " $data"?: OrganizationSwitcher_organizations$data;
readonly " $fragmentSpreads": FragmentRefs<"OrganizationSwitcher_organizations">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"organizations"
]
}
]
},
"name": "OrganizationSwitcher_organizations",
"selections": [
{
"alias": "organizations",
"args": null,
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "__OrganizationSwitcher_organizations_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
}
],
"type": "Viewer",
"abstractKey": null
};
(node as any).hash = "4bc97175bc64a6c7554a6ec865d12850";
export default node;

View File

@@ -0,0 +1,163 @@
import { graphql } from "relay-runtime";
import {
Button,
Tr,
Td,
Thead,
Tbody,
Th,
IconTrashCan,
Badge,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedControlsCardFragment$key } from "./__generated__/LinkedControlsCardFragment.graphql";
import { useFragment } from "react-relay";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedControlsDialog } from "./LinkedControlsDialog";
import { SortableTable, SortableTh } from "../SortableTable";
import type { ComponentProps } from "react";
const linkedControlFragment = graphql`
fragment LinkedControlsCardFragment on Control {
id
name
sectionTitle
framework {
id
name
}
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
controlId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Controls linked to the element
controls: (LinkedControlsCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to detach a control (will receive {controlId, ...params})
onDetach: Mutation<Params>;
// Mutation to attach a control (will receive {controlId, ...params})
onAttach?: Mutation<Params>;
// Allow sorting in the table
refetch: ComponentProps<typeof SortableTable>["refetch"];
};
/**
* Reusable component that displays a list of linked controls
*/
export function LinkedControlsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const controls = props.controls;
const onDetach = (controlId: string) => {
props.onDetach({
variables: {
input: {
controlId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onAttach = (controlId: string) => {
if (!props.onAttach) {
return;
}
props.onAttach({
variables: {
input: {
controlId,
...props.params,
},
connections: [props.connectionId],
},
});
};
return (
<SortableTable refetch={props.refetch as any}>
<Thead>
<Tr>
<SortableTh field="SECTION_TITLE">{__("Reference")}</SortableTh>
<Th>{__("Name")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{controls.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No controls linked")}
</Td>
</Tr>
)}
{controls.map((control) => (
<ControlRow
key={control.id}
control={control}
onClick={onDetach}
onAttach={onAttach}
/>
))}
<LinkedControlsDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedControls={controls}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3}>{__("Link control")}</TrButton>
</LinkedControlsDialog>
</Tbody>
</SortableTable>
);
}
function ControlRow(props: {
control: LinkedControlsCardFragment$key & { id: string };
onClick: (controlId: string) => void;
onAttach?: (controlId: string) => void;
}) {
const control = useFragment(linkedControlFragment, props.control);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr
to={`/organizations/${organizationId}/frameworks/${control.framework.id}/controls/${control.id}`}
>
<Td>
<span className="inline-flex gap-2 items-center">
{control.framework.name}{" "}
<Badge size="md">{control.sectionTitle}</Badge>
</span>
</Td>
<Td>{control.name}</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(control.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,199 @@
import {
Badge,
Button,
Dialog,
DialogContent,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import {
Suspense,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import type { LinkedControlsDialogQuery } from "./__generated__/LinkedControlsDialogQuery.graphql";
import type {
LinkedControlsDialogFragment$data,
LinkedControlsDialogFragment$key,
} from "./__generated__/LinkedControlsDialogFragment.graphql";
import type { NodeOf } from "/types";
import { useDebounceCallback } from "usehooks-ts";
const query = graphql`
query LinkedControlsDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...LinkedControlsDialogFragment
}
}
`;
const controlsFragment = graphql`
fragment LinkedControlsDialogFragment on Organization
@argumentDefinitions(
first: { type: "Int", defaultValue: 1 }
after: { type: "CursorKey" }
last: { type: "Int", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
order: { type: "ControlOrder", defaultValue: null }
filter: { type: "ControlFilter", defaultValue: null }
)
@refetchable(queryName: "LinkedControlsDialogControlsQuery") {
controls(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: $filter
) @connection(key: "LinkedControlsDialogControlsQuery_controls") {
edges {
node {
id
name
sectionTitle
framework {
name
}
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedControls?: { id: string }[];
onLink: (controlId: string) => void;
onUnlink: (controlId: string) => void;
};
type SearchRef = RefObject<{ search: (v: string) => void } | null>;
export function LinkedControlsDialog(props: Props) {
const { __ } = useTranslate();
const searchRef: SearchRef = useRef(null);
const contentRef = useRef<HTMLDivElement>(null);
const [minHeight, setMinHeight] = useState(0);
const onSearch = (v: string) => {
setMinHeight(contentRef.current?.clientHeight ?? 0);
searchRef.current?.search(v);
};
return (
<Dialog trigger={props.children} title={__("Link controls")}>
<DialogContent>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={onSearch}
/>
</div>
<div ref={contentRef}>
<Suspense
fallback={
<div style={{ minHeight }}>
<Spinner centered />
</div>
}
>
<LinkedControlsDialogContent {...props} ref={searchRef} />
</Suspense>
</div>
</DialogContent>
</Dialog>
);
}
function LinkedControlsDialogContent(props: Props & { ref: SearchRef }) {
const organizationId = useOrganizationId();
const mainData = useLazyLoadQuery<LinkedControlsDialogQuery>(query, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext, refetch } =
usePaginationFragment(
controlsFragment,
mainData.organization as LinkedControlsDialogFragment$key
);
const controls = data.controls?.edges?.map((edge) => edge.node) ?? [];
const controlIds = useMemo(() => {
return new Set(props.linkedControls?.map((c) => c.id) ?? []);
}, [props.linkedControls]);
props.ref.current = {
search: useDebounceCallback((v: string) => {
refetch({
first: 20,
filter: {
query: v,
},
});
}, 500),
};
return (
<>
<div className="divide-y divide-border-low">
{controls.map((control) => (
<ControlRow
key={control.id}
control={control}
controlIds={controlIds}
{...props}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
function ControlRow(
props: {
control: NodeOf<LinkedControlsDialogFragment$data["controls"]>;
controlIds: Set<string>;
} & Props
) {
const { __ } = useTranslate();
const isLinked = props.controlIds.has(props.control.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full text-start"
onClick={() => onClick(props.control.id)}
>
{props.control.sectionTitle} : {props.control.name}
<Badge>{props.control.framework.name}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,79 @@
/**
* @generated SignedSource<<780834b432440afd9363100bb20f2529>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedControlsCardFragment$data = {
readonly framework: {
readonly id: string;
readonly name: string;
};
readonly id: string;
readonly name: string;
readonly sectionTitle: string;
readonly " $fragmentType": "LinkedControlsCardFragment";
};
export type LinkedControlsCardFragment$key = {
readonly " $data"?: LinkedControlsCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedControlsCardFragment",
"selections": [
(v0/*: any*/),
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v0/*: any*/),
(v1/*: any*/)
],
"storageKey": null
}
],
"type": "Control",
"abstractKey": null
};
})();
(node as any).hash = "741820c65c4317cac48693023c3f9bcc";
export default node;

View File

@@ -0,0 +1,351 @@
/**
* @generated SignedSource<<68d29ea810c69f34b0da8d3e66b4eb5e>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ControlOrderField = "CREATED_AT" | "SECTION_TITLE";
export type OrderDirection = "ASC" | "DESC";
export type ControlFilter = {
query?: string | null | undefined;
};
export type ControlOrder = {
direction: OrderDirection;
field: ControlOrderField;
};
export type LinkedControlsDialogControlsQuery$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
filter?: ControlFilter | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: ControlOrder | null | undefined;
};
export type LinkedControlsDialogControlsQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsDialogFragment">;
};
};
export type LinkedControlsDialogControlsQuery = {
response: LinkedControlsDialogControlsQuery$data;
variables: LinkedControlsDialogControlsQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "filter"
},
v3 = {
"defaultValue": 1,
"kind": "LocalArgument",
"name": "first"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v5 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v6 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
},
v7 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v8 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v9 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v10 = {
"kind": "Variable",
"name": "filter",
"variableName": "filter"
},
v11 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v12 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v13 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v14 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v15 = [
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
v16 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "LinkedControlsDialogControlsQuery",
"selections": [
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "LinkedControlsDialogFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v4/*: any*/)
],
"kind": "Operation",
"name": "LinkedControlsDialogControlsQuery",
"selections": [
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v13/*: any*/),
(v14/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v15/*: any*/),
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v14/*: any*/),
(v16/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v16/*: any*/),
(v14/*: any*/)
],
"storageKey": null
},
(v13/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v15/*: any*/),
"filters": [
"orderBy",
"filter"
],
"handle": "connection",
"key": "LinkedControlsDialogControlsQuery_controls",
"kind": "LinkedHandle",
"name": "controls"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e9bcdc36129e6a05c59f344433459b66",
"id": null,
"metadata": {},
"name": "LinkedControlsDialogControlsQuery",
"operationKind": "query",
"text": "query LinkedControlsDialogControlsQuery(\n $after: CursorKey\n $before: CursorKey = null\n $filter: ControlFilter = null\n $first: Int = 1\n $last: Int = null\n $order: ControlOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...LinkedControlsDialogFragment_4cFWzS\n id\n }\n}\n\nfragment LinkedControlsDialogFragment_4cFWzS on Organization {\n controls(first: $first, after: $after, last: $last, before: $before, orderBy: $order, filter: $filter) {\n edges {\n node {\n id\n name\n sectionTitle\n framework {\n name\n id\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "9fc0bfbfc461d56748750b180bc232fc";
export default node;

View File

@@ -0,0 +1,248 @@
/**
* @generated SignedSource<<06b39b49e82019064bd42a03c903fd93>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedControlsDialogFragment$data = {
readonly controls: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly framework: {
readonly name: string;
};
readonly id: string;
readonly name: string;
readonly sectionTitle: string;
};
}>;
};
readonly id: string;
readonly " $fragmentType": "LinkedControlsDialogFragment";
};
export type LinkedControlsDialogFragment$key = {
readonly " $data"?: LinkedControlsDialogFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsDialogFragment">;
};
import LinkedControlsDialogControlsQuery_graphql from './LinkedControlsDialogControlsQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"controls"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "filter"
},
{
"defaultValue": 1,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": LinkedControlsDialogControlsQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "LinkedControlsDialogFragment",
"selections": [
{
"alias": "controls",
"args": [
{
"kind": "Variable",
"name": "filter",
"variableName": "filter"
},
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "__LinkedControlsDialogControlsQuery_controls_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "9fc0bfbfc461d56748750b180bc232fc";
export default node;

View File

@@ -0,0 +1,253 @@
/**
* @generated SignedSource<<f8a28e26c1260135c13f22f9d35019c2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedControlsDialogQuery$variables = {
organizationId: string;
};
export type LinkedControlsDialogQuery$data = {
readonly organization: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsDialogFragment">;
};
};
export type LinkedControlsDialogQuery = {
response: LinkedControlsDialogQuery$data;
variables: LinkedControlsDialogQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "LinkedControlsDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedControlsDialogFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "LinkedControlsDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v5/*: any*/),
(v2/*: any*/)
],
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "controls(first:1)"
},
{
"alias": null,
"args": (v4/*: any*/),
"filters": [
"orderBy",
"filter"
],
"handle": "connection",
"key": "LinkedControlsDialogControlsQuery_controls",
"kind": "LinkedHandle",
"name": "controls"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "a36d424a3b8a087dbb40149bdbc2b6a2",
"id": null,
"metadata": {},
"name": "LinkedControlsDialogQuery",
"operationKind": "query",
"text": "query LinkedControlsDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...LinkedControlsDialogFragment\n }\n}\n\nfragment LinkedControlsDialogFragment on Organization {\n controls(first: 1) {\n edges {\n node {\n id\n name\n sectionTitle\n framework {\n name\n id\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "1be60a2dc1efbd8992472862ca32910e";
export default node;

View File

@@ -0,0 +1,15 @@
interface ProgressBarProps {
value: number;
className?: string;
}
export function ProgressBar({ value, className }: ProgressBarProps) {
return (
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
IconTrashCan,
DocumentVersionBadge,
DocumentTypeBadge,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedDocumentsCardFragment$key } from "./__generated__/LinkedDocumentsCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedDocumentDialog } from "./LinkedDocumentsDialog.tsx";
import clsx from "clsx";
const linkedDocumentFragment = graphql`
fragment LinkedDocumentsCardFragment on Document {
id
title
createdAt
documentType
versions(first: 1) {
edges {
node {
id
status
}
}
}
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
documentId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Documents linked to the element
documents: (LinkedDocumentsCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a document (will receive {documentId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a document (will receive {documentId, ...params})
onDetach: Mutation<Params>;
variant?: "card" | "table";
};
/**
* Reusable component that displays a list of linked documents
*/
export function LinkedDocumentsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(4);
const documents = useMemo(() => {
return limit ? props.documents.slice(0, limit) : props.documents;
}, [props.documents, limit]);
const showMoreButton = limit !== null && props.documents.length > limit;
const variant = props.variant ?? "table";
const onAttach = (documentId: string) => {
props.onAttach({
variables: {
input: {
documentId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (documentId: string) => {
props.onDetach({
variables: {
input: {
documentId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Documents")}</div>
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link document")}
</Button>
</LinkedDocumentDialog>
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{documents.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No documents linked")}
</Td>
</Tr>
)}
{documents.map((document) => (
<DocumentRow
key={document.id}
document={document}
onClick={onDetach}
/>
))}
{variant === "table" && (
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={4} icon={IconPlusLarge}>
{__("Link document")}
</TrButton>
</LinkedDocumentDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.documents.length - limit)}
</Button>
)}
</Wrapper>
);
}
function DocumentRow(props: {
document: LinkedDocumentsCardFragment$key & { id: string };
onClick: (documentId: string) => void;
}) {
const document = useFragment(linkedDocumentFragment, props.document);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
<Td>
<div className="flex gap-4 items-center">
<img
src="/document.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
{document.title}
</div>
</Td>
<Td>
<DocumentTypeBadge type={document.documentType} />
</Td>
<Td>
<DocumentVersionBadge state={document.versions.edges[0].node.status} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(document.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,178 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DocumentTypeBadge,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import type { LinkedDocumentsDialogQuery } from "./__generated__/LinkedDocumentsDialogQuery.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { NodeOf } from "/types";
import type {
LinkedDocumentsDialogFragment$data,
LinkedDocumentsDialogFragment$key,
} from "./__generated__/LinkedDocumentsDialogFragment.graphql";
const documentsQuery = graphql`
query LinkedDocumentsDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
...LinkedDocumentsDialogFragment
}
}
}
`;
const documentsFragment = graphql`
fragment LinkedDocumentsDialogFragment on Organization
@refetchable(queryName: "LinkedDocumentsDialogQuery_fragment")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
order: { type: "DocumentOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
documents(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
edges {
node {
id
title
documentType
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedDocuments?: { id: string }[];
onLink: (documentId: string) => void;
onUnlink: (documentId: string) => void;
};
export function LinkedDocumentDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link documents")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedDocumentsDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedDocumentsDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const query = useLazyLoadQuery<LinkedDocumentsDialogQuery>(documentsQuery, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
documentsFragment,
query.organization as LinkedDocumentsDialogFragment$key
);
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const documents = data.documents?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedDocuments?.map((m) => m.id) ?? []);
}, [props.linkedDocuments]);
const filteredDocuments = useMemo(() => {
return documents.filter((document) =>
document.title.toLowerCase().includes(search.toLowerCase())
);
}, [documents, search]);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search documents...")}
onValueChange={setSearch}
/>
</div>
<div className="divide-y divide-border-low">
{filteredDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
linkedDocuments={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
type Document = NodeOf<LinkedDocumentsDialogFragment$data["documents"]>;
type RowProps = {
document: Document;
linkedDocuments: Set<string>;
disabled?: boolean;
onLink: (documentId: string) => void;
onUnlink: (documentId: string) => void;
};
function DocumentRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedDocuments.has(props.document.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full h-[100px]"
onClick={() => onClick(props.document.id)}
>
{props.document.title}
<DocumentTypeBadge type={props.document.documentType} />
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,126 @@
/**
* @generated SignedSource<<743c5a0c4d380bd05c0d8ccd7a664e58>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type DocumentStatus = "DRAFT" | "PUBLISHED";
export type DocumentType = "ISMS" | "OTHER" | "POLICY";
import { FragmentRefs } from "relay-runtime";
export type LinkedDocumentsCardFragment$data = {
readonly createdAt: any;
readonly documentType: DocumentType;
readonly id: string;
readonly title: string;
readonly versions: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly status: DocumentStatus;
};
}>;
};
readonly " $fragmentType": "LinkedDocumentsCardFragment";
};
export type LinkedDocumentsCardFragment$key = {
readonly " $data"?: LinkedDocumentsCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedDocumentsCardFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "DocumentVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
}
],
"type": "Document",
"abstractKey": null
};
})();
(node as any).hash = "952e653849ce0b1b693de8d7e3086e3f";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<a048edd52203c53c6736b800456816b0>>
* @generated SignedSource<<7b0ec493ab146db602cae612158407cb>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,27 +9,33 @@
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type DocumentType = "ISMS" | "OTHER" | "POLICY";
import { FragmentRefs } from "relay-runtime";
export type PeopleSelector_organization$data = {
readonly id: string;
readonly peoples: {
export type LinkedDocumentsDialogFragment$data = {
readonly documents: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly fullName: string;
readonly documentType: DocumentType;
readonly id: string;
readonly primaryEmailAddress: string;
readonly title: string;
};
}>;
};
readonly " $fragmentType": "PeopleSelector_organization";
readonly id: string;
readonly " $fragmentType": "LinkedDocumentsDialogFragment";
};
export type PeopleSelector_organization$key = {
readonly " $data"?: PeopleSelector_organization$data;
readonly " $fragmentSpreads": FragmentRefs<"PeopleSelector_organization">;
export type LinkedDocumentsDialogFragment$key = {
readonly " $data"?: LinkedDocumentsDialogFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
import LinkedDocumentsDialogQuery_fragment_graphql from './LinkedDocumentsDialogQuery_fragment.graphql';
const node: ReaderFragment = (function(){
var v0 = {
var v0 = [
"documents"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
@@ -37,44 +43,85 @@ var v0 = {
"storageKey": null
};
return {
"argumentDefinitions": [],
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"peoples"
]
"direction": "bidirectional",
"path": (v0/*: any*/)
}
]
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": LinkedDocumentsDialogQuery_fragment_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "PeopleSelector_organization",
"name": "LinkedDocumentsDialogFragment",
"selections": [
(v0/*: any*/),
{
"alias": "peoples",
"alias": "documents",
"args": [
{
"kind": "Literal",
"kind": "Variable",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "FULL_NAME"
}
"variableName": "order"
}
],
"concreteType": "PeopleConnection",
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "__PeopleSelector_organization_peoples_connection",
"name": "__LinkedDocumentsDialogQuery_documents_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
@@ -82,24 +129,24 @@ return {
{
"alias": null,
"args": null,
"concreteType": "People",
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"name": "documentType",
"storageKey": null
},
{
@@ -143,19 +190,34 @@ return {
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "__PeopleSelector_organization_peoples_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
}
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "77094e387ff3e5dca822fc2360094744";
(node as any).hash = "392d33b379a58cb3da08ab783c1cda5d";
export default node;

View File

@@ -0,0 +1,245 @@
/**
* @generated SignedSource<<6316cd819a27efb5989750b5f8de0444>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedDocumentsDialogQuery$variables = {
organizationId: string;
};
export type LinkedDocumentsDialogQuery$data = {
readonly organization: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
};
export type LinkedDocumentsDialogQuery = {
response: LinkedDocumentsDialogQuery$data;
variables: LinkedDocumentsDialogQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 20
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "LinkedDocumentsDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedDocumentsDialogFragment"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "LinkedDocumentsDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "documents",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "documents(first:20)"
},
{
"alias": null,
"args": (v4/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "LinkedDocumentsDialogQuery_documents",
"kind": "LinkedHandle",
"name": "documents"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "fd4ab41131ead5f74dceb4610ef3f2c5",
"id": null,
"metadata": {},
"name": "LinkedDocumentsDialogQuery",
"operationKind": "query",
"text": "query LinkedDocumentsDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n ...LinkedDocumentsDialogFragment\n }\n }\n}\n\nfragment LinkedDocumentsDialogFragment on Organization {\n documents(first: 20) {\n edges {\n node {\n id\n title\n documentType\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "f97a3016285b1a39cc6925ac39d82acc";
export default node;

View File

@@ -0,0 +1,318 @@
/**
* @generated SignedSource<<29bf3465293095c44e826394c602cde1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type DocumentOrderField = "CREATED_AT" | "TITLE";
export type OrderDirection = "ASC" | "DESC";
export type DocumentOrder = {
direction: OrderDirection;
field: DocumentOrderField;
};
export type LinkedDocumentsDialogQuery_fragment$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: DocumentOrder | null | undefined;
};
export type LinkedDocumentsDialogQuery_fragment$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
};
export type LinkedDocumentsDialogQuery_fragment = {
response: LinkedDocumentsDialogQuery_fragment$data;
variables: LinkedDocumentsDialogQuery_fragment$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
},
v6 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v13 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "LinkedDocumentsDialogQuery_fragment",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "LinkedDocumentsDialogFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "LinkedDocumentsDialogQuery_fragment",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "documents",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"storageKey": null
},
(v11/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "LinkedDocumentsDialogQuery_documents",
"kind": "LinkedHandle",
"name": "documents"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e72639ede26aa4300b69ba3181de482b",
"id": null,
"metadata": {},
"name": "LinkedDocumentsDialogQuery_fragment",
"operationKind": "query",
"text": "query LinkedDocumentsDialogQuery_fragment(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: DocumentOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...LinkedDocumentsDialogFragment_16fISc\n id\n }\n}\n\nfragment LinkedDocumentsDialogFragment_16fISc on Organization {\n documents(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n title\n documentType\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "392d33b379a58cb3da08ab783c1cda5d";
export default node;

View File

@@ -0,0 +1,56 @@
import type { ComponentProps, JSX, JSXElementConstructor } from "react";
import { Field } from "@probo/ui";
import { Controller, type Control } from "react-hook-form";
import { Select } from "@probo/ui";
type Props<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> =
ComponentProps<T> & {
control: Control<any>;
name: string;
};
export function ControlledField({
control,
name,
...props
}: Props<typeof Field>) {
return (
<Controller
control={control}
name={name}
render={({ field }) => (
<>
<Field
{...props}
{...field}
// TODO : Find a better way to handle this case (comparing number and string for select create issues)
value={field.value ? field.value.toString() : ""}
onValueChange={field.onChange}
/>
</>
)}
/>
);
}
export function ControlledSelect({
control,
name,
...props
}: Props<typeof Select>) {
return (
<Controller
control={control}
name={name}
render={({ field }) => (
<Select
id={name}
{...props}
{...field}
onValueChange={field.onChange}
value={field.value ?? ""}
/>
)}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Button, IconPlusLarge, IconTrashCan, Input, Label } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useFieldArray } from "react-hook-form";
import type { Control } from "react-hook-form";
import type { UseFormRegister } from "react-hook-form";
type Props = {
control: Control<any>;
register: UseFormRegister<any>;
};
/**
* A field to handle multiple emails
*/
export function EmailsField({ control, register }: Props) {
const { __ } = useTranslate();
const { fields, append, remove } = useFieldArray({
name: "additionalEmailAddresses",
control,
});
return (
<fieldset className="space-y-2">
{fields.length > 0 && <Label>{__("Additional emails")}</Label>}
{fields.map((field, index) => (
<div key={field.id} className="flex items-stretch">
<Input
className="w-full"
{...register(`additionalEmailAddresses.${index}`)}
type="email"
/>
<Button
icon={IconTrashCan}
variant="tertiary"
onClick={() => remove(index)}
/>
</div>
))}
<Button
variant="tertiary"
type="button"
icon={IconPlusLarge}
onClick={() => append("")}
>
{__("Add email")}
</Button>
</fieldset>
);
}

View File

@@ -0,0 +1,90 @@
import { Field, Option, Select } from "@probo/ui";
import { Suspense, useMemo, useState, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller } from "react-hook-form";
import { usePaginatedMeasures } from "/hooks/graph/usePaginatedMeasures";
type Props = {
organizationId: string;
control: Control<any>;
name: string;
label?: string;
error?: string;
disabled?: boolean;
} & ComponentProps<typeof Field>;
export function MeasureSelectField({
organizationId,
control,
disabled,
...props
}: Props) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<MeasureSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={disabled}
/>
</Suspense>
</Field>
);
}
function MeasureSelectWithQuery(
props: Pick<Props, "organizationId" | "control" | "name" | "disabled">
) {
const { __ } = useTranslate();
const { name, organizationId, control, disabled } = props;
const { data } = usePaginatedMeasures(organizationId);
const [search, setSearch] = useState("");
const measures = useMemo(() => {
return (
data?.measures.edges
?.filter(
(edge) =>
edge.node.name.toLowerCase().includes(search.toLowerCase()) ||
edge.node.description?.toLowerCase().includes(search.toLowerCase())
)
.map((edge) => edge.node) ?? []
);
}, [data?.measures.edges, search]);
return (
<div>
<Controller
control={control}
name={name}
render={({ field }) => (
<Select
id={name}
variant="editor"
placeholder={__("Select a measure")}
onValueChange={field.onChange}
{...field}
className="w-full"
value={field.value ?? ""}
onSearch={setSearch}
searchValue={search}
disabled={disabled}
>
{measures?.map((m) => (
<Option key={m.id} value={m.id}>
<div className="space-y-1 text-start min-w-0">
<div className="max-w-75 ellipsis overflow-hidden whitespace-pre-wrap">
{m.name}
</div>
<div className="text-sm text-txt-secondary">{m.category}</div>
</div>
</Option>
))}
</Select>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { Avatar, Field, Option, Select } from "@probo/ui";
import { Suspense, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller } from "react-hook-form";
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
type Props = {
organizationId: string;
control: Control<any>;
name: string;
label?: string;
error?: string;
} & ComponentProps<typeof Field>;
export function PeopleSelectField({
organizationId,
control,
...props
}: Props) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<PeopleSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
/>
</Suspense>
</Field>
);
}
function PeopleSelectWithQuery(
props: Pick<Props, "organizationId" | "control" | "name" | "disabled">
) {
const { __ } = useTranslate();
const { name, organizationId, control } = props;
const people = usePeople(organizationId);
return (
<>
<Controller
control={control}
name={name}
render={({ field }) => (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Select an owner")}
onValueChange={field.onChange}
key={people?.length.toString() ?? "0"}
{...field}
className="w-full"
value={field.value ?? ""}
>
{people?.map((p) => (
<Option key={p.id} value={p.id} className="flex gap-2">
<Avatar name={p.fullName} />
{p.fullName}
</Option>
))}
</Select>
)}
/>
</>
);
}

View File

@@ -0,0 +1,133 @@
import { Avatar, Field, Option, Select, Badge, Button, IconCrossLargeX } from "@probo/ui";
import { Suspense, useState, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller } from "react-hook-form";
import { useVendors } from "/hooks/graph/VendorGraph.ts";
import { faviconUrl } from "@probo/helpers";
type Props = {
organizationId: string;
control: Control<any>;
name: string;
label?: string;
error?: string;
} & ComponentProps<typeof Field>;
export function VendorsMultiSelectField({
organizationId,
control,
...props
}: Props) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<VendorsMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
/>
</Suspense>
</Field>
);
}
function VendorsMultiSelectWithQuery(
props: Pick<Props, "organizationId" | "control" | "name" | "disabled">
) {
const { __ } = useTranslate();
const { name, organizationId, control } = props;
const vendors = useVendors(organizationId);
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Controller
control={control}
name={name}
render={({ field }) => {
const selectedVendorIds = Array.isArray(field.value) ? field.value : [];
const selectedVendors = vendors.filter(v => selectedVendorIds.includes(v.id));
const availableVendors = vendors.filter(v => !selectedVendorIds.includes(v.id));
const handleAddVendor = (vendorId: string) => {
const newValue = [...selectedVendorIds, vendorId];
field.onChange(newValue);
setIsOpen(false);
};
const handleRemoveVendor = (vendorId: string) => {
const newValue = selectedVendorIds.filter((id: string) => id !== vendorId);
field.onChange(newValue);
};
return (
<div className="space-y-2">
{availableVendors.length > 0 && (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Add vendors...")}
onValueChange={handleAddVendor}
key={`${selectedVendorIds.length}-${vendors.length}`}
className="w-full"
value=""
open={isOpen}
onOpenChange={setIsOpen}
>
{availableVendors.map((vendor) => (
<Option key={vendor.id} value={vendor.id} className="flex gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
size="s"
/>
<div className="flex flex-col">
<span>{vendor.name}</span>
{vendor.websiteUrl && (
<span className="text-xs text-txt-secondary">
{vendor.websiteUrl}
</span>
)}
</div>
</Option>
))}
</Select>
)}
{selectedVendors.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedVendors.map((vendor) => (
<Badge key={vendor.id} variant="neutral" className="flex items-center gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
size="s"
/>
<span>{vendor.name}</span>
<Button
variant="tertiary"
icon={IconCrossLargeX}
onClick={() => handleRemoveVendor(vendor.id)}
className="h-4 w-4 p-0 hover:bg-transparent"
/>
</Badge>
))}
</div>
)}
{selectedVendors.length === 0 && availableVendors.length === 0 && (
<div className="text-sm text-txt-secondary py-2">
{__("No vendors available")}
</div>
)}
</div>
);
}}
/>
</>
);
}

View File

@@ -0,0 +1,190 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
MeasureBadge,
IconTrashCan,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedMeasuresCardFragment$key } from "./__generated__/LinkedMeasuresCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedMeasureDialog } from "./LinkedMeasuresDialog.tsx";
import clsx from "clsx";
const linkedMeasureFragment = graphql`
fragment LinkedMeasuresCardFragment on Measure {
id
name
state
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
measureId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Measures linked to the element
measures: (LinkedMeasuresCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a measure (will receive {measureId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a measure (will receive {measureId, ...params})
onDetach: Mutation<Params>;
variant?: "card" | "table";
};
/**
* Reusable component that displays a list of linked measures
*/
export function LinkedMeasuresCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(
props.variant === "card" ? 4 : null
);
const measures = useMemo(() => {
return limit ? props.measures.slice(0, limit) : props.measures;
}, [props.measures, limit]);
const showMoreButton = limit !== null && props.measures.length > limit;
const variant = props.variant ?? "table";
const onAttach = (measureId: string) => {
props.onAttach({
variables: {
input: {
measureId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (measureId: string) => {
props.onDetach({
variables: {
input: {
measureId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Measures")}</div>
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link measure")}
</Button>
</LinkedMeasureDialog>
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{measures.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
{__("No measures linked")}
</Td>
</Tr>
)}
{measures.map((measure) => (
<MeasureRow key={measure.id} measure={measure} onClick={onDetach} />
))}
{variant === "table" && (
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3} icon={IconPlusLarge}>
{__("Link measure")}
</TrButton>
</LinkedMeasureDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.measures.length - limit)}
</Button>
)}
</Wrapper>
);
}
function MeasureRow(props: {
measure: LinkedMeasuresCardFragment$key & { id: string };
onClick: (measureId: string) => void;
}) {
const measure = useFragment(linkedMeasureFragment, props.measure);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/measures/${measure.id}`}>
<Td>{measure.name}</Td>
<Td>
<MeasureBadge state={measure.state} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(measure.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,148 @@
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Option,
Select,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { usePaginatedMeasures } from "/hooks/graph/usePaginatedMeasures";
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedMeasures?: { id: string }[];
onLink: (measureId: string) => void;
onUnlink: (measureId: string) => void;
};
export function LinkedMeasureDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link measures")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedMeasuresDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedMeasuresDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const { data, loadNext, hasNext, isLoadingNext } =
usePaginatedMeasures(organizationId);
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null);
const measures = data.measures?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedMeasures?.map((m) => m.id) ?? []);
}, [props.linkedMeasures]);
const filteredMeasures = useMemo(() => {
return measures.filter(
(measure) =>
(category === null || measure.category === category) &&
(measure.name.toLowerCase().includes(search.toLowerCase()) ||
measure.description?.toLowerCase().includes(search.toLowerCase()))
);
}, [measures, search, category]);
const categories = useMemo(
() => Array.from(new Set(measures.map((m) => m.category))),
[measures]
);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={setSearch}
/>
<Select
value={category ?? ""}
placeholder={__("All categories")}
onValueChange={setCategory}
className="max-w-[180px]"
>
{categories.map((category) => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
</div>
<div className="divide-y divide-border-low">
{filteredMeasures.map((measure) => (
<MeasureRow
key={measure.id}
measure={measure}
linkedMeasures={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
type RowProps = {
measure: { name: string; category: string; id: string };
linkedMeasures: Set<string>;
disabled?: boolean;
onLink: (measureId: string) => void;
onUnlink: (measureId: string) => void;
};
function MeasureRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedMeasures.has(props.measure.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.measure.id)}
>
{props.measure.name}
<Badge variant="neutral">{props.measure.category}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,59 @@
/**
* @generated SignedSource<<b7482b43ce0f4dfab470c35e806acf50>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
import { FragmentRefs } from "relay-runtime";
export type LinkedMeasuresCardFragment$data = {
readonly id: string;
readonly name: string;
readonly state: MeasureState;
readonly " $fragmentType": "LinkedMeasuresCardFragment";
};
export type LinkedMeasuresCardFragment$key = {
readonly " $data"?: LinkedMeasuresCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedMeasuresCardFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"type": "Measure",
"abstractKey": null
};
(node as any).hash = "741a216c02732c1ff97b265ac8dbf39b";
export default node;

View File

@@ -0,0 +1,91 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
useDialogRef,
} from "@probo/ui";
import type { PropsWithChildren } from "react";
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
const inviteMutation = graphql`
mutation InviteUserDialogMutation($input: InviteUserInput!) {
inviteUser(input: $input) {
success
}
}
`;
const schema = z.object({
email: z.string().email(),
fullName: z.string(),
});
export function InviteUserDialog({ children }: PropsWithChildren) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, {
successMessage: __("User invited successfully"),
errorMessage: __("Failed to invite user"),
});
const { register, handleSubmit, formState } = useFormWithSchema(schema, {});
const dialogRef = useDialogRef();
const onSubmit = handleSubmit((data) => {
inviteUser({
variables: {
input: {
organizationId,
email: data.email,
fullName: data.fullName,
},
},
onSuccess: () => {
dialogRef.current?.close();
},
});
});
return (
<Dialog
title={__("Invite member")}
trigger={children}
className="max-w-lg"
ref={dialogRef}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">
<p className="text-txt-secondary text-sm">
Send an invitation to join your workspace.
</p>
<Field
type="email"
label={__("Email")}
placeholder={__("Email")}
{...register("email")}
error={formState.errors.email?.message}
/>
<Field
type="text"
label={__("Full name")}
placeholder={__("Full name")}
{...register("fullName")}
error={formState.errors.fullName?.message}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isInviting}>
{__("Invite user")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,94 @@
/**
* @generated SignedSource<<1cf40085b2959d7ebb48ee4a90be41b1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type InviteUserInput = {
email: string;
fullName: string;
organizationId: string;
};
export type InviteUserDialogMutation$variables = {
input: InviteUserInput;
};
export type InviteUserDialogMutation$data = {
readonly inviteUser: {
readonly success: boolean;
};
};
export type InviteUserDialogMutation = {
response: InviteUserDialogMutation$data;
variables: InviteUserDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "InviteUserPayload",
"kind": "LinkedField",
"name": "inviteUser",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "InviteUserDialogMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "InviteUserDialogMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "aa667927b5e2cd0019a8457edc286181",
"id": null,
"metadata": {},
"name": "InviteUserDialogMutation",
"operationKind": "mutation",
"text": "mutation InviteUserDialogMutation(\n $input: InviteUserInput!\n) {\n inviteUser(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "de4c5b5208a2ff0953e9d15b844df842";
export default node;

View File

@@ -0,0 +1,148 @@
import { graphql } from "relay-runtime";
import {
Button,
IconTrashCan,
RiskBadge,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedRisksCardFragment$key } from "./__generated__/LinkedRisksCardFragment.graphql";
import { useFragment } from "react-relay";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedRisksDialog } from "./LinkedRisksDialog.tsx";
const linkedRiskFragment = graphql`
fragment LinkedRisksCardFragment on Risk {
id
name
inherentRiskScore
residualRiskScore
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
riskId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Risks linked to the element
risks: (LinkedRisksCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a risk (will receive {riskId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a risk (will receive {riskId, ...params})
onDetach: Mutation<Params>;
};
/**
* Reusable component that displays a list of linked risks
*/
export function LinkedRisksCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const onAttach = (riskId: string) => {
props.onAttach({
variables: {
input: {
riskId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (riskId: string) => {
props.onDetach({
variables: {
input: {
riskId,
...props.params,
},
connections: [props.connectionId],
},
});
};
return (
<div className="space-y-4 relative">
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Inherent Risk")}</Th>
<Th>{__("Residual Risk")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{props.risks.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No risks linked")}
</Td>
</Tr>
)}
{props.risks.map((risk) => (
<RiskRow key={risk.id} risk={risk} onClick={onDetach} />
))}
<LinkedRisksDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedRisks={props.risks}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={4}>{__("Link risk")}</TrButton>
</LinkedRisksDialog>
</Tbody>
</Table>
</div>
);
}
function RiskRow(props: {
risk: LinkedRisksCardFragment$key & { id: string };
onClick: (riskId: string) => void;
}) {
const risk = useFragment(linkedRiskFragment, props.risk);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/risks/${risk.id}`}>
<Td>{risk.name}</Td>
<Td>
<RiskBadge level={risk.inherentRiskScore} />
</Td>
<Td>
<RiskBadge level={risk.residualRiskScore} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(risk.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,172 @@
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
Input,
Option,
Select,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type { LinkedRisksDialogQuery } from "./__generated__/LinkedRisksDialogQuery.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
const risksQuery = graphql`
query LinkedRisksDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
risks(first: 100) {
edges {
node {
id
name
category
description
inherentRiskScore
residualRiskScore
}
}
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedRisks?: { id: string }[];
onLink: (riskId: string) => void;
onUnlink: (riskId: string) => void;
};
export function LinkedRisksDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link risks")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedRisksDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedRisksDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const data = useLazyLoadQuery<LinkedRisksDialogQuery>(risksQuery, {
organizationId,
});
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null);
const risks = data.organization?.risks?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedRisks?.map((r) => r.id) ?? []);
}, [props.linkedRisks]);
const filteredRisks = useMemo(() => {
return risks.filter(
(risk) =>
(category === null || risk.category === category) &&
(risk.name.toLowerCase().includes(search.toLowerCase()) ||
risk.description?.toLowerCase().includes(search.toLowerCase()))
);
}, [risks, search, category]);
const categories = useMemo(
() => Array.from(new Set(risks.map((r) => r.category))),
[risks]
);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search risks...")}
onValueChange={setSearch}
/>
<Select
value={category ?? ""}
placeholder={__("All categories")}
onValueChange={setCategory}
className="max-w-[180px]"
>
{categories.map((category) => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
</div>
<div className="divide-y divide-border-low">
{filteredRisks.map((risk) => (
<RiskRow
key={risk.id}
risk={risk}
linkedRisks={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
</div>
</>
);
}
type RowProps = {
risk: {
name: string;
category: string;
id: string;
inherentRiskScore: number;
residualRiskScore: number;
};
linkedRisks: Set<string>;
disabled?: boolean;
onLink: (riskId: string) => void;
onUnlink: (riskId: string) => void;
};
function RiskRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedRisks.has(props.risk.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.risk.id)}
>
<div className="text-left">{props.risk.name}</div>
<Badge variant="neutral">{props.risk.category}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,66 @@
/**
* @generated SignedSource<<6e092be20526b76ee767836880803c34>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedRisksCardFragment$data = {
readonly id: string;
readonly inherentRiskScore: number;
readonly name: string;
readonly residualRiskScore: number;
readonly " $fragmentType": "LinkedRisksCardFragment";
};
export type LinkedRisksCardFragment$key = {
readonly " $data"?: LinkedRisksCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedRisksCardFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedRisksCardFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentRiskScore",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualRiskScore",
"storageKey": null
}
],
"type": "Risk",
"abstractKey": null
};
(node as any).hash = "32a84445ae1cc071f56139fa44d67690";
export default node;

View File

@@ -0,0 +1,206 @@
/**
* @generated SignedSource<<ac10f3fa76fc76951f4c08e38ef0a185>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type LinkedRisksDialogQuery$variables = {
organizationId: string;
};
export type LinkedRisksDialogQuery$data = {
readonly organization: {
readonly id: string;
readonly risks?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly inherentRiskScore: number;
readonly name: string;
readonly residualRiskScore: number;
};
}>;
};
};
};
export type LinkedRisksDialogQuery = {
response: LinkedRisksDialogQuery$data;
variables: LinkedRisksDialogQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "risks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentRiskScore",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualRiskScore",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "risks(first:100)"
}
],
"type": "Organization",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "LinkedRisksDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "LinkedRisksDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "ccac9f72628186de135aeb2ee176207d",
"id": null,
"metadata": {},
"name": "LinkedRisksDialogQuery",
"operationKind": "query",
"text": "query LinkedRisksDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n risks(first: 100) {\n edges {\n node {\n id\n name\n category\n description\n inherentRiskScore\n residualRiskScore\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "a0a7e39135cc4c1a4a74c4728902f944";
export default node;

View File

@@ -0,0 +1,12 @@
import { PageHeader, Skeleton } from "@probo/ui";
export function PageSkeleton() {
return (
<div className="space-y-6">
<PageHeader title={<Skeleton className="w-40 h-8" />} />
<div>
<Skeleton style={{ aspectRatio: "1280/280" }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Button, IconPlusLarge, PageHeader, Skeleton } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
export function RisksPageSkeleton() {
const { __ } = useTranslate();
return (
<div className="space-y-6">
<PageHeader title={__("Risks")}>
<Button icon={IconPlusLarge} disabled>
{__("New Risk")}
</Button>
</PageHeader>
<div className="grid grid-cols-2 gap-4">
<Skeleton className="aspect-square" />
<Skeleton className="aspect-square" />
</div>
<div>
<Skeleton style={{ aspectRatio: "1280/280" }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DurationPicker,
Input,
Label,
PropertyRow,
Textarea,
useDialogRef,
type DialogRef,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useTranslate } from "@probo/i18n";
import { Breadcrumb } from "@probo/ui";
import { graphql } from "relay-runtime";
import { useFragment } from "react-relay";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { PeopleSelectField } from "/components/form/PeopleSelectField";
import type { TaskFormDialogFragment$key } from "./__generated__/TaskFormDialogFragment.graphql";
import { MeasureSelectField } from "/components/form/MeasureSelectField";
import { Controller } from "react-hook-form";
const taskFragment = graphql`
fragment TaskFormDialogFragment on Task {
id
description
name
state
timeEstimate
deadline
assignedTo {
id
}
measure {
id
}
}
`;
const taskCreateMutation = graphql`
mutation TaskFormDialogCreateMutation(
$input: CreateTaskInput!
$connections: [ID!]!
) {
createTask(input: $input) {
taskEdge @prependEdge(connections: $connections) {
node {
...TaskFormDialogFragment
}
}
}
}
`;
export const taskUpdateMutation = graphql`
mutation TaskFormDialogUpdateMutation($input: UpdateTaskInput!) {
updateTask(input: $input) {
task {
...TaskFormDialogFragment
}
}
}
`;
const schema = z.object({
name: z.string(),
description: z.string(),
timeEstimate: z.string().nullable(),
assignedToId: z.string(),
measureId: z.string(),
deadline: z.date({
coerce: true,
}),
});
type Props = {
children?: ReactNode;
task?: TaskFormDialogFragment$key;
connection?: string;
ref?: DialogRef;
measureId?: string;
};
export default function TaskFormDialog(props: Props) {
const { __ } = useTranslate();
const dialogRef = props.ref ?? useDialogRef();
const organizationId = useOrganizationId();
const task = useFragment(taskFragment, props.task);
const [mutate] = task
? useMutationWithToasts(taskUpdateMutation, {
successMessage: __("Task updated successfully."),
errorMessage: __("Failed to update task. Please try again."),
})
: useMutationWithToasts(taskCreateMutation, {
successMessage: __("Task created successfully."),
errorMessage: __("Failed to create task. Please try again."),
});
const { control, handleSubmit, register, formState } = useFormWithSchema(
schema,
{
defaultValues: {
name: task?.name ?? "",
description: task?.description ?? "",
timeEstimate: task?.timeEstimate ?? "",
assignedToId: task?.assignedTo?.id ?? "",
measureId: task?.measure?.id ?? props.measureId ?? "",
deadline: task?.deadline.split("T")[0] ?? new Date(),
},
}
);
const onSubmit = handleSubmit(async (data) => {
if (task) {
await mutate({
variables: {
input: {
taskId: task.id,
name: data.name,
description: data.description,
timeEstimate: data.timeEstimate || null,
deadline: data.deadline,
},
},
});
} else {
await mutate({
variables: {
input: {
organizationId,
name: data.name,
description: data.description,
timeEstimate: data.timeEstimate || null,
deadline: data.deadline,
assignedToId: data.assignedToId,
measureId: data.measureId,
},
connections: [props.connection!],
},
});
}
dialogRef.current?.close();
});
const isUpdating = !!task;
const showMeasure = !props.measureId && !isUpdating;
const isCreating = !isUpdating;
return (
<Dialog
ref={dialogRef}
trigger={props.children}
title={
<Breadcrumb
items={[__("Tasks"), isUpdating ? __("Edit Task") : __("New Task")]}
/>
}
>
<form onSubmit={onSubmit}>
<DialogContent className="grid grid-cols-[1fr_420px]">
<div className="py-8 px-10 space-y-4">
<Input
id="title"
required
variant="title"
placeholder={__("Task title")}
{...register("name")}
/>
<Textarea
id="content"
variant="ghost"
autogrow
placeholder={__("Add description")}
{...register("description")}
/>
</div>
{/* Properties form */}
<div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label>
{isCreating && (
<PropertyRow
label={__("Assigned to")}
error={formState.errors.assignedToId?.message}
>
<PeopleSelectField
name="assignedToId"
control={control}
organizationId={organizationId}
/>
</PropertyRow>
)}
{showMeasure && (
<PropertyRow
label={__("Measure")}
error={formState.errors.measureId?.message}
>
<MeasureSelectField
name="measureId"
control={control}
organizationId={organizationId}
/>
</PropertyRow>
)}
<PropertyRow
label={__("Time estimate")}
error={formState.errors.timeEstimate?.message}
>
<Controller
name="timeEstimate"
control={control}
render={({ field: { onChange, ...field } }) => (
<DurationPicker
{...field}
onValueChange={(value) => onChange(value)}
/>
)}
/>
</PropertyRow>
<PropertyRow
label={__("Deadline")}
error={formState.errors.deadline?.message}
>
<Input id="deadline" type="date" {...register("deadline")} />
</PropertyRow>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit">
{isUpdating ? __("Update task") : __("Create task")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,232 @@
import {
ActionDropdown,
Avatar,
Card,
DropdownItem,
IconArrowCornerDownLeft,
IconPencil,
IconTrashCan,
PriorityLevel,
Spinner,
TabBadge,
TabItem,
Tabs,
TaskStateIcon,
useConfirm,
useDialogRef,
} from "@probo/ui";
import { Fragment } from "react";
import { graphql, useMutation } from "react-relay";
import { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks";
import type { ItemOf } from "/types";
import TaskFormDialog, {
taskUpdateMutation,
} from "/components/tasks/TaskFormDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Link, useLocation } from "react-router";
import { promisifyMutation } from "@probo/helpers";
import type { TaskFormDialogFragment$key } from "./__generated__/TaskFormDialogFragment.graphql";
type Props = {
tasks: ({
assignedTo?: {
id: string;
fullName: string;
} | null;
id: string;
name: string;
state: "TODO" | "DONE";
description: string;
measure?: {
id: string;
name: string;
} | null;
} & TaskFormDialogFragment$key)[];
connectionId: string;
};
export default function TasksCard({ tasks, connectionId }: Props) {
const { __ } = useTranslate();
const hash = useLocation().hash.replace("#", "");
const hashes = [
{ hash: "", label: __("To do"), state: "TODO" },
{ hash: "done", label: __("Done"), state: "DONE" },
{ hash: "all", label: __("All"), state: null },
] as const;
const tasksPerHash = new Map([
["", tasks?.filter((t) => t.state === "TODO")],
["done", tasks?.filter((t) => t.state === "DONE")],
["all", tasks],
]);
const filteredTasks = tasksPerHash.get(hash) ?? [];
usePageTitle(__("Tasks"));
return (
<div className="space-y-6">
{tasks?.length === 0 ? (
<p className="text-center py-6 text-txt-secondary">{__("No tasks")}</p>
) : (
<Card>
<Tabs className="px-6">
{hashes.map((h) => (
<TabItem asChild active={hash === h.hash} key={h.hash}>
<Link to={`#${h.hash}`}>
{h.label}
<TabBadge>{tasksPerHash.get(h.hash)?.length}</TabBadge>
</Link>
</TabItem>
))}
</Tabs>
<div className="divide-y divide-border-solid">
{hash === "all"
? // All tabs group the todo using the state
hashes
.slice(0, 2)
.filter((h) => tasksPerHash.get(h.hash)?.length)
.map((h) => (
<Fragment key={h.label}>
<h2 className="px-6 py-3 text-sm font-medium flex items-center gap-2 bg-subtle">
<TaskStateIcon state={h.state!} />
{h.label}
</h2>
{tasksPerHash
.get(h.hash)
?.map((task) => (
<TaskRow
key={task.id}
task={task}
connectionId={connectionId}
/>
))}
</Fragment>
))
: // Todo and Done tab simply list todos
filteredTasks?.map((task) => (
<TaskRow
key={task.id}
task={task}
connectionId={connectionId}
/>
))}
</div>
</Card>
)}
</div>
);
}
type TaskRowProps = {
task: ItemOf<Props["tasks"]> & TaskFormDialogFragment$key;
connectionId: string;
};
const deleteMutation = graphql`
mutation TasksCardDeleteMutation(
$input: DeleteTaskInput!
$connections: [ID!]!
) {
deleteTask(input: $input) {
deletedTaskId @deleteEdge(connections: $connections)
}
}
`;
function TaskRow(props: TaskRowProps) {
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const { __ } = useTranslate();
const confirm = useConfirm();
const [deleteTask] = useMutation(deleteMutation);
const [updateTask, isUpdating] = useMutation(taskUpdateMutation);
const onToggle = () => {
promisifyMutation(updateTask)({
variables: {
input: {
taskId: props.task.id,
state: props.task.state === "TODO" ? "DONE" : "TODO",
},
},
});
};
const onDelete = () => {
confirm(
() =>
promisifyMutation(deleteTask)({
variables: {
input: { taskId: props.task.id },
connections: [props.connectionId],
},
}),
{
message: "Are you sure you want to delete this task?",
}
);
};
return (
<>
<TaskFormDialog task={props.task} ref={dialogRef} />
<div className="flex items-center justify-between py-3 px-6">
<div className="flex gap-2 items-start">
<div className="flex items-center gap-2 pt-[2px]">
<PriorityLevel level={1} />
<button
onClick={onToggle}
className="cursor-pointer -m-1 p-1 disabled:opacity-60"
disabled={isUpdating}
>
<TaskStateIcon state={props.task.state} />
</button>
</div>
<div className="text-sm space-y-1">
<h2 className="font-medium">{props.task.name}</h2>
{props.task.measure && (
<p className="text-txt-secondary flex items-center gap-2">
<IconArrowCornerDownLeft className="scale-x-[-1]" size={16} />
<Link
className="hover:underline"
to={`/organizations/${organizationId}/measures/${props.task.measure?.id}`}
>
{props.task.measure?.name}
</Link>
</p>
)}
</div>
</div>
<div className="flex gap-2 items-center">
{isUpdating && <Spinner size={16} />}
{props.task.assignedTo && (
<Link
to={`/organizations/${organizationId}/people/${props.task.assignedTo?.id}`}
>
<Avatar name={props.task.assignedTo?.fullName ?? ""} />
</Link>
)}
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => dialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,249 @@
/**
* @generated SignedSource<<beed7398f44a7093301b72d7bceeeba1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreateTaskInput = {
assignedToId?: string | null | undefined;
deadline?: any | null | undefined;
description: string;
measureId?: string | null | undefined;
name: string;
organizationId: string;
timeEstimate?: any | null | undefined;
};
export type TaskFormDialogCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateTaskInput;
};
export type TaskFormDialogCreateMutation$data = {
readonly createTask: {
readonly taskEdge: {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"TaskFormDialogFragment">;
};
};
};
};
export type TaskFormDialogCreateMutation = {
response: TaskFormDialogCreateMutation$data;
variables: TaskFormDialogCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v4 = [
(v3/*: any*/)
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "TaskFormDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateTaskPayload",
"kind": "LinkedField",
"name": "createTask",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "taskEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "TaskFormDialogFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "TaskFormDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateTaskPayload",
"kind": "LinkedField",
"name": "createTask",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "taskEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deadline",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "taskEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "f605aed8a6f83a622d32e9b42f52f524",
"id": null,
"metadata": {},
"name": "TaskFormDialogCreateMutation",
"operationKind": "mutation",
"text": "mutation TaskFormDialogCreateMutation(\n $input: CreateTaskInput!\n) {\n createTask(input: $input) {\n taskEdge {\n node {\n ...TaskFormDialogFragment\n id\n }\n }\n }\n}\n\nfragment TaskFormDialogFragment on Task {\n id\n description\n name\n state\n timeEstimate\n deadline\n assignedTo {\n id\n }\n measure {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "3a5194da3b1d57be836ca5e3405b2c3a";
export default node;

View File

@@ -0,0 +1,115 @@
/**
* @generated SignedSource<<d6d04868c777f81982de3bffb9a7e73f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type TaskState = "DONE" | "TODO";
import { FragmentRefs } from "relay-runtime";
export type TaskFormDialogFragment$data = {
readonly assignedTo: {
readonly id: string;
} | null | undefined;
readonly deadline: any | null | undefined;
readonly description: string;
readonly id: string;
readonly measure: {
readonly id: string;
} | null | undefined;
readonly name: string;
readonly state: TaskState;
readonly timeEstimate: any | null | undefined;
readonly " $fragmentType": "TaskFormDialogFragment";
};
export type TaskFormDialogFragment$key = {
readonly " $data"?: TaskFormDialogFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"TaskFormDialogFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = [
(v0/*: any*/)
];
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "TaskFormDialogFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deadline",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": (v1/*: any*/),
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": (v1/*: any*/),
"storageKey": null
}
],
"type": "Task",
"abstractKey": null
};
})();
(node as any).hash = "3a4bede2199df797a20a6d87358d41cf";
export default node;

View File

@@ -0,0 +1,199 @@
/**
* @generated SignedSource<<1cc9998f8dcbb02ac977744023d372d6>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type TaskState = "DONE" | "TODO";
export type UpdateTaskInput = {
deadline?: any | null | undefined;
description?: string | null | undefined;
name?: string | null | undefined;
state?: TaskState | null | undefined;
taskId: string;
timeEstimate?: any | null | undefined;
};
export type TaskFormDialogUpdateMutation$variables = {
input: UpdateTaskInput;
};
export type TaskFormDialogUpdateMutation$data = {
readonly updateTask: {
readonly task: {
readonly " $fragmentSpreads": FragmentRefs<"TaskFormDialogFragment">;
};
};
};
export type TaskFormDialogUpdateMutation = {
response: TaskFormDialogUpdateMutation$data;
variables: TaskFormDialogUpdateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = [
(v2/*: any*/)
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "TaskFormDialogUpdateMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "UpdateTaskPayload",
"kind": "LinkedField",
"name": "updateTask",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "task",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "TaskFormDialogFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "TaskFormDialogUpdateMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "UpdateTaskPayload",
"kind": "LinkedField",
"name": "updateTask",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "task",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deadline",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": (v3/*: any*/),
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": (v3/*: any*/),
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "bfcbdf0470627b6f7b9a98a1722f7dca",
"id": null,
"metadata": {},
"name": "TaskFormDialogUpdateMutation",
"operationKind": "mutation",
"text": "mutation TaskFormDialogUpdateMutation(\n $input: UpdateTaskInput!\n) {\n updateTask(input: $input) {\n task {\n ...TaskFormDialogFragment\n id\n }\n }\n}\n\nfragment TaskFormDialogFragment on Task {\n id\n description\n name\n state\n timeEstimate\n deadline\n assignedTo {\n id\n }\n measure {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "7c174671b0235b09cfe0fa98d4b3e629";
export default node;

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<2866738fc7aeb6abd91cf4ed4ae72727>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteTaskInput = {
taskId: string;
};
export type TasksCardDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteTaskInput;
};
export type TasksCardDeleteMutation$data = {
readonly deleteTask: {
readonly deletedTaskId: string;
};
};
export type TasksCardDeleteMutation = {
response: TasksCardDeleteMutation$data;
variables: TasksCardDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedTaskId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "TasksCardDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteTaskPayload",
"kind": "LinkedField",
"name": "deleteTask",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "TasksCardDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteTaskPayload",
"kind": "LinkedField",
"name": "deleteTask",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedTaskId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "1ab085f9650841990644d2c106effac7",
"id": null,
"metadata": {},
"name": "TasksCardDeleteMutation",
"operationKind": "mutation",
"text": "mutation TasksCardDeleteMutation(\n $input: DeleteTaskInput!\n) {\n deleteTask(input: $input) {\n deletedTaskId\n }\n}\n"
}
};
})();
(node as any).hash = "803ffe0cb54f7fa5c840f6d3e4f2592b";
export default node;

View File

@@ -1,48 +0,0 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-subtle-bg",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,36 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-active-b focus:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary-bg text-invert",
secondary: "bg-secondary-bg border border-low-b",
info: "bg-info-bg text-info border-info-b",
success: "bg-success-bg text-success border-success-b",
warning: "bg-warning-bg text-warning border-warning-b",
destructive: "bg-danger-bg text-danger border-danger-b",
outline: "",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -1,115 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-3 break-words text-sm text-secondary sm:gap-2.5",
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-primary", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-primary", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5 text-quaternary", className)}
{...props}
>
{children ?? "/"}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -1,57 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"rounded-full cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-active-b disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary-bg text-invert shadow-sm hover:bg-h-primary-bg",
destructive:
"bg-danger-plain-bg text-invert shadow-xs hover:bg-danger-plain-bg/90",
outline:
"border border-low-b hover:bg-h-tertiary-bg active:bg-p-tertiary-bg focus:bg-tertiary-bg shadow-xs",
secondary:
"bg-secondary-bg border border-low-b shadow-sm hover:bg-h-secondary-bg",
ghost:
"hover:bg-h-tertiary-bg active:bg-p-tertiary-bg focus:bg-tertiary-bg",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,80 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-level-1", className)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-tertiary", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -1,28 +0,0 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
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,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -1,9 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,153 +0,0 @@
import * as React from "react";
import { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
// Add the missing type definition
type CommandDialogProps = DialogProps & {
children?: React.ReactNode;
};
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-level-0 text-primary",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-tertiary [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" data-cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
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,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
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,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-solid-b", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
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,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-tertiary", className)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -1,119 +0,0 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
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,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
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,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-active focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary-bg data-[state=open]:text-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-secondary", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,201 +0,0 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
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,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
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,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
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,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
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,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
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,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-subtle-bg", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -1,22 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<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,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -1,24 +0,0 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -1,29 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
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,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -1,26 +0,0 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -1,42 +0,0 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
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,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -1,91 +0,0 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
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,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"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,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
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,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
};

View File

@@ -1,29 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-solid-b",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -1,140 +0,0 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<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,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-level-0 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-level-0 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-active-b focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-invert">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-primary", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-tertiary", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -1,772 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for measure from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-level-0",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-level-0 text-secondary",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-level-0 p-0 text-secondary [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-secondary md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"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)",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
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,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex gap-2 h-full w-full flex-col bg-level-0 group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-solid-b group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-solid-b group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"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,
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
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,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-level-0 shadow-none focus-visible:ring-2 focus-visible:ring-active-b",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-solid-b", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-0", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
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,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-secondary outline-hidden ring-active-b transition-transform hover:bg-accent-bg hover:text-accent focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"rounded-full text-tertiary active:text-primary data-[active=true]:text-primary peer/menu-button flex w-full items-center gap-2 overflow-hidden p-2 text-left text-sm outline-hidden ring-active-b transition-[width,height,padding] focus-visible:ring disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 hover:cursor-pointer",
{
variants: {
variant: {
default:
"hover:bg-h-active-bg data-[state=open]:bg-active-bg data-[state=open]:hover:bg-h-active-bg active:bg-active-bg data-[active=true]:bg-active-bg",
outline:
"border border-low-b shadow-[0_0_0_1px_hsl(var(--solid-b))] hover:bg-h-tertiary-bg data-[state=open]:hover:bg-h-tertiary-bg hover:shadow-[0_0_0_1px_hsl(var(--accent))] active:bg-tertiary-bg ata-[active=true]:bg-h-tertiary-bg",
ghost:
"shadow-[0_0_0_1px_hsl(var(--solid-b))] hover:bg-h-tertiary-bg data-[state=open]:hover:bg-h-tertiary-bg hover:shadow-[0_0_0_1px_hsl(var(--accent))] active:bg-tertiary-bg ata-[active=true]:bg-h-tertiary-bg",
},
size: {
default: "h-10 py-2 px-3 gap-3",
sm: "h-8 text-sm",
lg: "h-12 py-2 px-3 gap-3 group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-6 items-center justify-center rounded-md p-0 text-secondary outline-hidden ring-active-b transition-transform hover:bg-accent-bg hover:text-accent focus-visible:ring-2 peer-hover/menu-button:text-accent [&>svg]:size-4 [&>svg]:shrink-0 hover:cursor-pointer",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-2 peer-data-[size=default]/menu-button:right-2",
"peer-data-[size=lg]/menu-button:top-2.5",
"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,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-secondary",
"peer-hover/menu-button:text-accent peer-data-[active=true]/menu-button:text-accent",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
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,
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-secondary flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-4 outline-hidden ring-active-b hover:bg-accent-bg hover:text-primary focus-visible:ring-2 active:bg-accent-bg active:text-primary disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-accent-bg data-[active=true]:text-primary",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -1,15 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -1,29 +0,0 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
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,
)}
{...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",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -1,114 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-invert", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-h-subtle-bg data-[state=selected]:bg-subtle-bg",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.HTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-tertiary [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.HTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-tertiary", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -1,52 +0,0 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center rounded-md p-1 text-tertiary",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"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,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-level-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,24 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
className?: string;
}
>(({ className, ...props }, ref) => {
return (
<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,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -1,127 +0,0 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
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,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all select-text data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-level-0 text-primary",
destructive:
"destructive group border-danger-b bg-danger-bg text-danger",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
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,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
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,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90 select-text", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -1,33 +0,0 @@
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -1,30 +0,0 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
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,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };