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,323 +0,0 @@
@import "tailwindcss";
@import "@radix-ui/colors/blue.css";
@import "@radix-ui/colors/blue-dark.css";
@import "@radix-ui/colors/olive.css";
@import "@radix-ui/colors/olive-alpha.css";
@import "@radix-ui/colors/olive-dark.css";
@import "@radix-ui/colors/olive-dark-alpha.css";
@import "@radix-ui/colors/lime.css";
@import "@radix-ui/colors/lime-dark.css";
@import "@radix-ui/colors/red.css";
@import "@radix-ui/colors/red-dark.css";
@import "@radix-ui/colors/red-dark-alpha.css";
@import "@radix-ui/colors/orange.css";
@import "@radix-ui/colors/orange-dark.css";
@import "./styles/document-content.css";
@plugin 'tailwindcss-animate';
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme {
/* Legacy */
/* --color-border: hsl(var(--border)); -> solid-b
--color-input: hsl(var(--input)); -> solid-b
--color-ring: hsl(var(--ring)); -> active-b
--color-background: hsl(var(--background)); -> level-0
--color-foreground: hsl(var(--foreground)); -> primary
--color-primary: hsl(var(--primary)); -> primary
--color-primary-foreground: hsl(var(--primary-foreground)); -> invert
--color-secondary: hsl(var(--secondary)); -> invert
--color-secondary-foreground: hsl(var(--secondary-foreground)); -> secondary
--color-destructive: hsl(var(--destructive)); -> danger-bg
--color-destructive-foreground: hsl(var(--destructive-foreground)); -> danger
--color-muted: hsl(var(--muted)); -> invert-bg
--color-muted-foreground: hsl(var(--muted-foreground)); -> tertiary
--color-accent: hsl(var(--accent)); -> accent-bg
--color-accent-foreground: hsl(var(--accent-foreground)); -> accent
--color-popover: hsl(var(--popover)); -> level-0
--color-popover-foreground: hsl(var(--popover-foreground)); -> primary
--color-card: hsl(var(--card)); -> level-0
--color-card-foreground: hsl(var(--card-foreground)); -> primary
--color-sidebar: hsl(var(--sidebar-background)); -> level-0
--color-sidebar-foreground: hsl(var(--sidebar-foreground)); -> secondary
--color-sidebar-primary: hsl(var(--sidebar-primary)); -> not used
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border)); -> solid-b
--color-sidebar-ring: hsl(var(--sidebar-ring)); -> active-b */
/* New */
/* Surfaces */
--color-level-0: var(--level-0);
--color-level-1: var(--level-1);
--color-level-2: var(--level-2);
--color-level-3: var(--level-3);
/* Text colors */
--color-primary: var(--primary);
--color-invert: var(--invert);
--color-secondary: var(--secondary);
--color-tertiary: var(--tertiary);
--color-quaternary: var(--quaternary);
--color-disabled: var(--disabled);
--color-accent: var(--accent);
--color-danger: var(--danger);
--color-success: var(--success);
--color-info: var(--info);
--color-warning: var(--warning);
/* Background colors */
--color-primary-bg: var(--primary-bg);
--color-invert-bg: var(--invert-bg);
--color-secondary-bg: var(--secondary-bg);
--color-tertiary-bg: var(--tertiary-bg);
--color-subtle-bg: var(--subtle-bg);
--color-highlight-bg: var(--highlight-bg);
--color-accent-bg: var(--accent-bg);
--color-active-bg: var(--active-bg);
--color-danger-bg: var(--danger-bg);
--color-danger-plain-bg: var(--danger-plain-bg);
--color-success-bg: var(--success-bg);
--color-info-bg: var(--info-bg);
--color-warning-bg: var(--warning-bg);
/* Hover */
--color-h-primary-bg: var(--h-primary-bg);
--color-h-invert-bg: var(--h-invert-bg);
--color-h-secondary-bg: var(--h-secondary-bg);
--color-h-tertiary-bg: var(--h-tertiary-bg);
--color-h-subtle-bg: var(--h-subtle-bg);
--color-h-highlight-bg: var(--h-highlight-bg);
--color-h-accent-bg: var(--h-accent-bg);
--color-h-active-bg: var(--h-active-bg);
/* Pressed */
--color-p-primary-bg: var(--p-primary-bg);
--color-p-invert-bg: var(--p-invert-bg);
--color-p-secondary-bg: var(--p-secondary-bg);
--color-p-tertiary-bg: var(--p-tertiary-bg);
--color-p-subtle-bg: var(--p-subtle-bg);
--color-p-highlight-bg: var(--p-highlight-bg);
--color-p-accent-bg: var(--p-accent-bg);
--color-p-active-bg: var(--p-active-bg);
/* Borders */
--color-solid-b: var(--solid-b);
--color-low-b: var(--low-b);
--color-mid-b: var(--mid-b);
--color-strong-b: var(--strong-b);
--color-active-b: var(--active-b);
--color-accent-b: var(--accent-b);
--color-danger-b: var(--danger-b);
--color-success-b: var(--success-b);
--color-warning-b: var(--warning-b);
--color-info-b: var(--info-b);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-mid-b, currentColor);
}
}
@layer base {
/* Legacy */
/* :root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
} */
/* New */
:root {
/* Surfaces */
--level-0: #FAFAF5;
--level-1: #FFFFFF;
--level-2: #FFFFFF;
--level-3: var(--olive-12);
/* Text colors */
--primary: var(--olive-12);
--invert: var(--olive-1);
--secondary: var(--olive-11);
--tertiary: var(--olive-10);
--quaternary: var(--olive-8);
--disabled: var(--olive-7);
--accent: var(--lime-10);
--danger: var(--red-11);
--success: var(--lime-11);
--info: var(--blue-11);
--warning: var(--orange-11);
/* Background colors */
--primary-bg: var(--olive-12);
--invert-bg: #ffffff;
--secondary-bg: var(--olive-1);
--tertiary-bg: transparent;
--subtle-bg: var(--olive-a2);
--highlight-bg: var(--olive-a3);
--accent-bg: var(--olive-12);
--active-bg: var(--lime-4);
--danger-bg: var(--red-3);
--danger-plain-bg: var(--red-10);
--success-bg: var(--lime-3);
--info-bg: var(--blue-3);
--warning-bg: var(--orange-3);
/* Hover */
--h-primary-bg: #273025;
--h-invert-bg: var(--olive-2);
--h-secondary-bg: var(--olive-2);
--h-tertiary-bg: var(--olive-a2);
--h-subtle-bg: var(--olive-a3);
--h-highlight-bg: var(--olive-a4);
--h-accent-bg: #1E2C1C;
--h-active-bg: var(--lime-5);
/* Pressed */
--p-primary-bg: #30392E;
--p-invert-bg: var(--olive-3);
--p-secondary-bg: var(--olive-3);
--p-tertiary-bg: var(--olive-a3);
--p-subtle-bg: var(--olive-a4);
--p-highlight-bg: var(--olive-a5);
--p-accent-bg: #293A25;
--p-active-bg: var(--lime-6);
/* Borders */
--solid-b: var(--olive-4);
--low-b: var(--olive-a4);
--mid-b: var(--olive-a7);
--strong-b: var(--olive-a8);
--active-b: var(--olive-12);
--accent-b: var(--lime-11);
--danger-b: var(--red-10);
--success-b: var(--lime-10);
--warning-b: var(--orange-10);
--info-b: var(--blue-10);
}
.dark {
/* Surfaces */
--level-0: var(--olive-1);
--level-1: var(--olive-2);
--level-2: var(--olive-3);
--level-3: var(--olive-7);
/* Text colors */
--success: var(--lime-a11);
/* Background colors */
--primary-bg: var(--lime-10);
--invert-bg: var(--olive-1);
--secondary-bg: var(--olive-a3);
--active-bg: var(--olive-a4);
--accent-bg: var(--lime-10);
--danger-bg: var(--red-a3);
--danger-plain-bg: var(--red-a10);
--success-bg: var(--lime-a3);
--info-bg: var(--blue-a3);
--warning-bg: var(--orange-a3);
/* Hover */
--h-primary-bg: #C4F042E1;
--h-secondary-bg: var(--olive-a3);
--h-highlight-bg: var(--olive-a5);
--h-accent-bg: var(--lime-a9);
--h-active-bg: var(--olive-a5);
/* Pressed */
--p-primary-bg: #C4F042D7;
--p-secondary-bg: var(--olive-a4);
--p-highlight-bg: var(--olive-a6);
--p-accent-bg: var(--lime-a10);
--p-active-bg: var(--olive-a6);
/* Borders */
--solid-b: var(--olive-5);
--mid-b: var(--olive-a5);
--active-b: var(--olive-a12);
--warning-b: var(--orange-a10);
}
}
@layer base {
* {
@apply border-solid-b;
}
body {
@apply font-sans antialiased bg-level-0 text-primary;
}
}

View File

@@ -1,91 +1,12 @@
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { lazy } from "@probo/react-lazy";
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { RelayEnvironmentProvider } from "react-relay";
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import "./App.css";
import "./styles/document-content.css";
import ErrorBoundary from "./components/ErrorBoundary";
import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import { AuthenticationRoutes } from "./pages/authentication/Routes";
import { OrganizationsRoutes } from "./pages/organizations/Routes";
import SigningRequestsPage from "./pages/SigningRequestsPage";
import { RouterProvider } from "react-router";
import { router } from "./routes";
import { Toasts } from "@probo/ui";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
session_recording: {
maskAllInputs: true,
},
loaded: (posthog) => {
if (!process.env.POSTHOG_KEY) posthog.debug();
},
});
const OrganizationSelectionPage = lazy(
() => import("./pages/OrganizationSelectionPage"),
);
function App() {
export function App() {
return (
<StrictMode>
<PostHogProvider client={posthog}>
<RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider>
<BrowserRouter>
<Routes>
<Route path="/*">
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<OrganizationSelectionPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="organizations/*"
element={<OrganizationsRoutes />}
/>
<Route
path="documents/signing-requests"
element={<SigningRequestsPage />}
/>
<Route
path="*"
element={
<AuthLayout>
<AuthenticationRoutes />
</AuthLayout>
}
/>
</Route>
</Routes>
</BrowserRouter>
</HelmetProvider>
</RelayEnvironmentProvider>
</PostHogProvider>
</StrictMode>
<>
<RouterProvider router={router} />
<Toasts />
</>
);
}
function ErrorBoundaryWithLocation({
children,
}: {
children: React.ReactNode;
}) {
const location = useLocation();
return <ErrorBoundary key={location.pathname}>{children}</ErrorBoundary>;
}
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);

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

@@ -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

@@ -0,0 +1,223 @@
/**
* @generated SignedSource<<7b0ec493ab146db602cae612158407cb>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type DocumentType = "ISMS" | "OTHER" | "POLICY";
import { FragmentRefs } from "relay-runtime";
export type LinkedDocumentsDialogFragment$data = {
readonly documents: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly documentType: DocumentType;
readonly id: string;
readonly title: string;
};
}>;
};
readonly id: string;
readonly " $fragmentType": "LinkedDocumentsDialogFragment";
};
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 = [
"documents"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"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": "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": "LinkedDocumentsDialogFragment",
"selections": [
{
"alias": "documents",
"args": [
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "__LinkedDocumentsDialogQuery_documents_connection",
"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": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"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 = "392d33b379a58cb3da08ab783c1cda5d";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<6c7503b4c94cbe6c062d634f0192a3ef>>
* @generated SignedSource<<6316cd819a27efb5989750b5f8de0444>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,17 +10,18 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NewRiskViewQuery$variables = {
export type LinkedDocumentsDialogQuery$variables = {
organizationId: string;
};
export type NewRiskViewQuery$data = {
export type LinkedDocumentsDialogQuery$data = {
readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleSelector_organization">;
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
};
export type NewRiskViewQuery = {
response: NewRiskViewQuery$data;
variables: NewRiskViewQuery$variables;
export type LinkedDocumentsDialogQuery = {
response: LinkedDocumentsDialogQuery$data;
variables: LinkedDocumentsDialogQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -42,29 +43,21 @@ v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 100
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "FULL_NAME"
}
"value": 20
}
];
return {
@@ -72,7 +65,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "NewRiskViewQuery",
"name": "LinkedDocumentsDialogQuery",
"selections": [
{
"alias": "organization",
@@ -82,10 +75,18 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "PeopleSelector_organization"
"kind": "InlineFragment",
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedDocumentsDialogFragment"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
@@ -98,7 +99,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "NewRiskViewQuery",
"name": "LinkedDocumentsDialogQuery",
"selections": [
{
"alias": "organization",
@@ -108,23 +109,23 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "PeopleConnection",
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "peoples",
"name": "documents",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
@@ -132,27 +133,27 @@ return {
{
"alias": null,
"args": null,
"concreteType": "People",
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: 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
},
(v2/*: any*/)
(v3/*: any*/)
],
"storageKey": null
},
@@ -187,12 +188,26 @@ 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": "peoples(first:100,orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
"storageKey": "documents(first:20)"
},
{
"alias": null,
@@ -201,9 +216,9 @@ return {
"orderBy"
],
"handle": "connection",
"key": "PeopleSelector_organization_peoples",
"key": "LinkedDocumentsDialogQuery_documents",
"kind": "LinkedHandle",
"name": "peoples"
"name": "documents"
}
],
"type": "Organization",
@@ -215,16 +230,16 @@ return {
]
},
"params": {
"cacheID": "395a2ea0b09e5fdc165a19757d0b637a",
"cacheID": "fd4ab41131ead5f74dceb4610ef3f2c5",
"id": null,
"metadata": {},
"name": "NewRiskViewQuery",
"name": "LinkedDocumentsDialogQuery",
"operationKind": "query",
"text": "query NewRiskViewQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ...PeopleSelector_organization\n id\n }\n}\n\nfragment PeopleSelector_organization on Organization {\n id\n peoples(first: 100, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
"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 = "f62e2420e6fd2b9d90840df308fb6c1c";
(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

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c68c659132315eb3f36849f37a95fef8>>
* @generated SignedSource<<1cf40085b2959d7ebb48ee4a90be41b1>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,17 +14,17 @@ export type InviteUserInput = {
fullName: string;
organizationId: string;
};
export type SettingsViewInviteUserMutation$variables = {
export type InviteUserDialogMutation$variables = {
input: InviteUserInput;
};
export type SettingsViewInviteUserMutation$data = {
export type InviteUserDialogMutation$data = {
readonly inviteUser: {
readonly success: boolean;
};
};
export type SettingsViewInviteUserMutation = {
response: SettingsViewInviteUserMutation$data;
variables: SettingsViewInviteUserMutation$variables;
export type InviteUserDialogMutation = {
response: InviteUserDialogMutation$data;
variables: InviteUserDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -66,7 +66,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsViewInviteUserMutation",
"name": "InviteUserDialogMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -75,20 +75,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsViewInviteUserMutation",
"name": "InviteUserDialogMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "08abe9c261dc68b1c76991ad5e6f4d95",
"cacheID": "aa667927b5e2cd0019a8457edc286181",
"id": null,
"metadata": {},
"name": "SettingsViewInviteUserMutation",
"name": "InviteUserDialogMutation",
"operationKind": "mutation",
"text": "mutation SettingsViewInviteUserMutation(\n $input: InviteUserInput!\n) {\n inviteUser(input: $input) {\n success\n }\n}\n"
"text": "mutation InviteUserDialogMutation(\n $input: InviteUserInput!\n) {\n inviteUser(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "5e95d05ee3c0d58f9413790a4a9516bf";
(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

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c70fd910b41f49426916f3f3aa1433bd>>
* @generated SignedSource<<2866738fc7aeb6abd91cf4ed4ae72727>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
export type DeleteTaskInput = {
taskId: string;
};
export type MeasureViewDeleteTaskMutation$variables = {
export type TasksCardDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteTaskInput;
};
export type MeasureViewDeleteTaskMutation$data = {
export type TasksCardDeleteMutation$data = {
readonly deleteTask: {
readonly deletedTaskId: string;
};
};
export type MeasureViewDeleteTaskMutation = {
response: MeasureViewDeleteTaskMutation$data;
variables: MeasureViewDeleteTaskMutation$variables;
export type TasksCardDeleteMutation = {
response: TasksCardDeleteMutation$data;
variables: TasksCardDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -59,7 +59,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "MeasureViewDeleteTaskMutation",
"name": "TasksCardDeleteMutation",
"selections": [
{
"alias": null,
@@ -84,7 +84,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "MeasureViewDeleteTaskMutation",
"name": "TasksCardDeleteMutation",
"selections": [
{
"alias": null,
@@ -117,16 +117,16 @@ return {
]
},
"params": {
"cacheID": "a5096acddc7796fbc89521cc80b5538e",
"cacheID": "1ab085f9650841990644d2c106effac7",
"id": null,
"metadata": {},
"name": "MeasureViewDeleteTaskMutation",
"name": "TasksCardDeleteMutation",
"operationKind": "mutation",
"text": "mutation MeasureViewDeleteTaskMutation(\n $input: DeleteTaskInput!\n) {\n deleteTask(input: $input) {\n deletedTaskId\n }\n}\n"
"text": "mutation TasksCardDeleteMutation(\n $input: DeleteTaskInput!\n) {\n deleteTask(input: $input) {\n deletedTaskId\n }\n}\n"
}
};
})();
(node as any).hash = "42867b2a8776a0facd18b0b24499ccc9";
(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 };

View File

@@ -0,0 +1,133 @@
/**
* @generated SignedSource<<38b7fae585eb2c09409935db8ad3cfe9>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type RiskTreatment = "ACCEPTED" | "AVOIDED" | "MITIGATED" | "TRANSFERRED";
import { FragmentRefs } from "relay-runtime";
export type useRiskFormFragment$data = {
readonly category: string;
readonly description: string;
readonly id: string;
readonly inherentImpact: number;
readonly inherentLikelihood: number;
readonly name: string;
readonly note: string;
readonly owner: {
readonly id: string;
} | null | undefined;
readonly residualImpact: number;
readonly residualLikelihood: number;
readonly treatment: RiskTreatment;
readonly " $fragmentType": "useRiskFormFragment";
};
export type useRiskFormFragment$key = {
readonly " $data"?: useRiskFormFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"useRiskFormFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "useRiskFormFragment",
"selections": [
(v0/*: 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": "treatment",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentLikelihood",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "inherentImpact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualLikelihood",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "residualImpact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "note",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "owner",
"plural": false,
"selections": [
(v0/*: any*/)
],
"storageKey": null
}
],
"type": "Risk",
"abstractKey": null
};
})();
(node as any).hash = "7ad9386b4049af3f139d7269bb5229c2";
export default node;

View File

@@ -0,0 +1,178 @@
/**
* @generated SignedSource<<ea3e7fd6a3b7dd5f78622e9f21cdb1d3>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type useVendorFormFragment$data = {
readonly businessOwner: {
readonly id: string;
} | null | undefined;
readonly certifications: ReadonlyArray<string>;
readonly dataProcessingAgreementUrl: string | null | undefined;
readonly description: string | null | undefined;
readonly headquarterAddress: string | null | undefined;
readonly id: string;
readonly legalName: string | null | undefined;
readonly name: string;
readonly privacyPolicyUrl: string | null | undefined;
readonly securityOwner: {
readonly id: string;
} | null | undefined;
readonly securityPageUrl: string | null | undefined;
readonly serviceLevelAgreementUrl: string | null | undefined;
readonly statusPageUrl: string | null | undefined;
readonly termsOfServiceUrl: string | null | undefined;
readonly trustPageUrl: string | null | undefined;
readonly websiteUrl: string | null | undefined;
readonly " $fragmentType": "useVendorFormFragment";
};
export type useVendorFormFragment$key = {
readonly " $data"?: useVendorFormFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"useVendorFormFragment">;
};
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": "useVendorFormFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "statusPageUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "termsOfServiceUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "privacyPolicyUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "serviceLevelAgreementUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "dataProcessingAgreementUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "legalName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "headquarterAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "certifications",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "securityPageUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "trustPageUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "businessOwner",
"plural": false,
"selections": (v1/*: any*/),
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "securityOwner",
"plural": false,
"selections": (v1/*: any*/),
"storageKey": null
}
],
"type": "Vendor",
"abstractKey": null
};
})();
(node as any).hash = "87f1029f8c634a5f7efdb6d7abe17709";
export default node;

View File

@@ -0,0 +1,268 @@
/**
* @generated SignedSource<<95aa70195a3e90acb366441fd037c2f2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type VendorCategory = "ANALYTICS" | "CLOUD_MONITORING" | "CLOUD_PROVIDER" | "COLLABORATION" | "CUSTOMER_SUPPORT" | "DATA_STORAGE_AND_PROCESSING" | "DOCUMENT_MANAGEMENT" | "EMPLOYEE_MANAGEMENT" | "ENGINEERING" | "FINANCE" | "IDENTITY_PROVIDER" | "IT" | "MARKETING" | "OFFICE_OPERATIONS" | "OTHER" | "PASSWORD_MANAGEMENT" | "PRODUCT_AND_DESIGN" | "PROFESSIONAL_SERVICES" | "RECRUITING" | "SALES" | "SECURITY" | "VERSION_CONTROL";
export type UpdateVendorInput = {
businessAssociateAgreementUrl?: string | null | undefined;
businessOwnerId?: string | null | undefined;
category?: VendorCategory | null | undefined;
certifications?: ReadonlyArray<string> | null | undefined;
dataProcessingAgreementUrl?: string | null | undefined;
description?: string | null | undefined;
headquarterAddress?: string | null | undefined;
id: string;
legalName?: string | null | undefined;
name?: string | null | undefined;
privacyPolicyUrl?: string | null | undefined;
securityOwnerId?: string | null | undefined;
securityPageUrl?: string | null | undefined;
serviceLevelAgreementUrl?: string | null | undefined;
statusPageUrl?: string | null | undefined;
subprocessorsListUrl?: string | null | undefined;
termsOfServiceUrl?: string | null | undefined;
trustPageUrl?: string | null | undefined;
websiteUrl?: string | null | undefined;
};
export type useVendorFormMutation$variables = {
input: UpdateVendorInput;
};
export type useVendorFormMutation$data = {
readonly updateVendor: {
readonly vendor: {
readonly " $fragmentSpreads": FragmentRefs<"useVendorFormFragment">;
};
};
};
export type useVendorFormMutation = {
response: useVendorFormMutation$data;
variables: useVendorFormMutation$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": "useVendorFormMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "UpdateVendorPayload",
"kind": "LinkedField",
"name": "updateVendor",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "vendor",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "useVendorFormFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "useVendorFormMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "UpdateVendorPayload",
"kind": "LinkedField",
"name": "updateVendor",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "vendor",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "statusPageUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "termsOfServiceUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "privacyPolicyUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "serviceLevelAgreementUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "dataProcessingAgreementUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "legalName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "headquarterAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "certifications",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "securityPageUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "trustPageUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "businessOwner",
"plural": false,
"selections": (v3/*: any*/),
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "securityOwner",
"plural": false,
"selections": (v3/*: any*/),
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "324b4589b4692806f555489703521bcd",
"id": null,
"metadata": {},
"name": "useVendorFormMutation",
"operationKind": "mutation",
"text": "mutation useVendorFormMutation(\n $input: UpdateVendorInput!\n) {\n updateVendor(input: $input) {\n vendor {\n ...useVendorFormFragment\n id\n }\n }\n}\n\nfragment useVendorFormFragment on Vendor {\n id\n name\n description\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n serviceLevelAgreementUrl\n dataProcessingAgreementUrl\n websiteUrl\n legalName\n headquarterAddress\n certifications\n securityPageUrl\n trustPageUrl\n businessOwner {\n id\n }\n securityOwner {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "f49cfca25ffd99e8bbdc2e2e3c78b400";
export default node;

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import { useFormWithSchema } from "../useFormWithSchema";
export const documentSchema = z.object({
title: z.string(),
content: z.string(),
ownerId: z.string(),
documentType: z.enum(["OTHER", "ISMS", "POLICY"]),
});
export const useDocumentForm = () => {
return useFormWithSchema(documentSchema, {
defaultValues: {
documentType: "POLICY",
},
});
};

View File

@@ -0,0 +1,64 @@
import { z } from "zod";
import { useFormWithSchema } from "../useFormWithSchema";
import { graphql } from "relay-runtime";
import type {
useRiskFormFragment$data,
useRiskFormFragment$key,
} from "./__generated__/useRiskFormFragment.graphql";
import { useFragment } from "react-relay";
const RiskFragment = graphql`
fragment useRiskFormFragment on Risk {
id
name
category
description
treatment
inherentLikelihood
inherentImpact
residualLikelihood
residualImpact
note
owner {
id
}
}
`;
export type RiskNode = useRiskFormFragment$data;
export type RiskKey = useRiskFormFragment$key & { id: string };
// Export the schema so it can be used elsewhere
export const riskSchema = z.object({
category: z.string(),
name: z.string(),
description: z.string(),
ownerId: z.string(),
treatment: z.enum(["AVOIDED", "MITIGATED", "TRANSFERRED", "ACCEPTED"]),
inherentLikelihood: z.number({ coerce: true }).min(1).max(5),
inherentImpact: z.number({ coerce: true }).min(1).max(5),
residualLikelihood: z.number({ coerce: true }).min(1).max(5),
residualImpact: z.number({ coerce: true }).min(1).max(5),
note: z.string().optional(),
});
export const useRiskForm = (riskKey?: RiskKey) => {
const risk = useFragment(RiskFragment, riskKey);
return useFormWithSchema(riskSchema, {
defaultValues: risk
? {
...risk,
ownerId: risk.owner?.id,
}
: {
inherentLikelihood: 3,
inherentImpact: 3,
residualLikelihood: 3,
residualImpact: 3,
},
});
};
export type RiskForm = ReturnType<typeof useRiskForm>;
export type RiskData = z.infer<typeof riskSchema>;

View File

@@ -0,0 +1,108 @@
import { z } from "zod";
import { useFormWithSchema } from "../useFormWithSchema";
import { graphql } from "relay-runtime";
import { useFragment } from "react-relay";
import type { useVendorFormFragment$key } from "./__generated__/useVendorFormFragment.graphql";
import { useMutationWithToasts } from "../useMutationWithToasts";
import { useTranslate } from "@probo/i18n";
const schema = z.object({
name: z.string(),
description: z.string(),
statusPageUrl: z.string(),
termsOfServiceUrl: z.string(),
privacyPolicyUrl: z.string(),
serviceLevelAgreementUrl: z.string(),
dataProcessingAgreementUrl: z.string(),
websiteUrl: z.string(),
legalName: z.string(),
headquarterAddress: z.string(),
certifications: z.array(z.string()),
securityPageUrl: z.string(),
trustPageUrl: z.string(),
businessOwnerId: z.string(),
securityOwnerId: z.string(),
});
const vendorFormFragment = graphql`
fragment useVendorFormFragment on Vendor {
id
name
description
statusPageUrl
termsOfServiceUrl
privacyPolicyUrl
serviceLevelAgreementUrl
dataProcessingAgreementUrl
websiteUrl
legalName
headquarterAddress
certifications
securityPageUrl
trustPageUrl
businessOwner {
id
}
securityOwner {
id
}
}
`;
const vendorUpdateQuery = graphql`
mutation useVendorFormMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor {
...useVendorFormFragment
}
}
}
`;
export function useVendorForm(vendorKey: useVendorFormFragment$key) {
const vendor = useFragment(vendorFormFragment, vendorKey);
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(vendorUpdateQuery, {
successMessage: __("Vendor updated successfully."),
errorMessage: __("Failed to update vendor. Please try again."),
});
const form = useFormWithSchema(schema, {
defaultValues: {
name: vendor.name,
description: vendor.description ?? "",
statusPageUrl: vendor.statusPageUrl ?? "",
termsOfServiceUrl: vendor.termsOfServiceUrl ?? "",
privacyPolicyUrl: vendor.privacyPolicyUrl ?? "",
serviceLevelAgreementUrl: vendor.serviceLevelAgreementUrl ?? "",
dataProcessingAgreementUrl: vendor.dataProcessingAgreementUrl ?? "",
websiteUrl: vendor.websiteUrl ?? "",
legalName: vendor.legalName ?? "",
headquarterAddress: vendor.headquarterAddress ?? "",
certifications: [...(vendor.certifications ?? [])],
securityPageUrl: vendor.securityPageUrl ?? "",
trustPageUrl: vendor.trustPageUrl ?? "",
businessOwnerId: vendor.businessOwner?.id,
securityOwnerId: vendor.securityOwner?.id,
},
});
const handleSubmit = form.handleSubmit((data) => {
return mutate({
variables: {
input: {
id: vendor.id,
...data,
},
},
}).then(() => {
form.reset(data);
});
});
return {
...form,
handleSubmit,
};
}

View File

@@ -0,0 +1,268 @@
import { graphql } from "relay-runtime";
import { useMutation, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import { useConfirm } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useMemo } from "react";
import type { AssetGraphListQuery } from "./__generated__/AssetGraphListQuery.graphql";
export const assetsQuery = graphql`
query AssetGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
assets(first: 100) @connection(key: "AssetsPage_assets") {
__id
edges {
node {
id
name
amount
criticity
assetType
dataTypesStored
owner {
fullName
}
vendors(first: 50) {
edges {
node {
id
name
websiteUrl
}
}
}
createdAt
}
}
}
}
}
}
`;
export const assetNodeQuery = graphql`
query AssetGraphNodeQuery($assetId: ID!) {
node(id: $assetId) {
... on Asset {
id
name
amount
criticity
assetType
dataTypesStored
owner {
id
fullName
}
vendors(first: 50) {
edges {
node {
id
name
websiteUrl
category
}
}
}
createdAt
updatedAt
}
}
}
`;
export const createAssetMutation = graphql`
mutation AssetGraphCreateMutation(
$input: CreateAssetInput!
$connections: [ID!]!
) {
createAsset(input: $input) {
assetEdge @prependEdge(connections: $connections) {
node {
id
name
amount
criticity
assetType
dataTypesStored
owner {
id
fullName
}
vendors(first: 10) {
edges {
node {
id
name
websiteUrl
}
}
}
createdAt
}
}
}
}
`;
export const updateAssetMutation = graphql`
mutation AssetGraphUpdateMutation($input: UpdateAssetInput!) {
updateAsset(input: $input) {
asset {
id
name
amount
criticity
assetType
dataTypesStored
owner {
id
fullName
}
vendors(first: 50) {
edges {
node {
id
name
websiteUrl
}
}
}
updatedAt
}
}
}
`;
export const deleteAssetMutation = graphql`
mutation AssetGraphDeleteMutation(
$input: DeleteAssetInput!
$connections: [ID!]!
) {
deleteAsset(input: $input) {
deletedAssetId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteAsset = (
asset: { id?: string; name?: string },
connectionId: string
) => {
const [mutate] = useMutation(deleteAssetMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!asset.id || !asset.name) {
return alert(__("Failed to delete asset: missing id or name"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
assetId: asset.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
'This will permanently delete "%s". This action cannot be undone.'
),
asset.name
),
}
);
};
};
export const useCreateAsset = (connectionId: string) => {
const [mutate] = useMutation(createAssetMutation);
const { __ } = useTranslate();
return (input: {
name: string;
amount: number;
criticity: string;
assetType: string;
ownerId: string;
organizationId: string;
vendorIds?: string[];
dataTypesStored: string;
}) => {
if (!input.name?.trim()) {
return alert(__("Failed to create asset: name is required"));
}
if (!input.ownerId) {
return alert(__("Failed to create asset: owner is required"));
}
if (!input.organizationId) {
return alert(__("Failed to create asset: organization is required"));
}
if (!input.dataTypesStored) {
return alert(__("Failed to create asset: data types stored is required"));
}
return promisifyMutation(mutate)({
variables: {
input: {
name: input.name,
amount: input.amount,
criticity: input.criticity,
assetType: input.assetType,
dataTypesStored: input.dataTypesStored || "",
ownerId: input.ownerId,
organizationId: input.organizationId,
vendorIds: input.vendorIds || [],
},
connections: [connectionId],
},
});
};
};
export const useUpdateAsset = () => {
const [mutate] = useMutation(updateAssetMutation);
const { __ } = useTranslate();
return (input: {
id: string;
name?: string;
amount?: number;
criticity?: string;
assetType?: string;
dataTypesStored?: string;
ownerId?: string;
vendorIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update asset: asset ID is required"));
}
return promisifyMutation(mutate)({
variables: {
input,
},
});
};
};
export const useAssets = (queryRef: PreloadedQuery<AssetGraphListQuery>) => {
const data = usePreloadedQuery(assetsQuery, queryRef);
return useMemo(() => {
const organization = data.node;
if (!organization || !organization.assets) {
return { assets: [], connectionId: "" };
}
return {
assets: organization.assets.edges.map((edge) => edge.node),
connectionId: organization.assets.__id,
};
}, [data]);
};

View File

@@ -0,0 +1,242 @@
import { graphql } from "relay-runtime";
import { useMutation, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import { useConfirm } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useMemo } from "react";
import type { DatumGraphListQuery } from "./__generated__/DatumGraphListQuery.graphql";
export const dataQuery = graphql`
query DatumGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
data(first: 100) @connection(key: "DataPage_data") {
__id
edges {
node {
id
name
dataClassification
owner {
fullName
}
vendors(first: 50) {
edges {
node {
id
name
websiteUrl
}
}
}
createdAt
}
}
}
}
}
}
`;
export const datumNodeQuery = graphql`
query DatumGraphNodeQuery($dataId: ID!) {
node(id: $dataId) {
... on Datum {
id
name
dataClassification
owner {
id
fullName
}
vendors(first: 50) {
edges {
node {
id
name
websiteUrl
category
}
}
}
organization {
id
}
createdAt
updatedAt
}
}
}
`;
export const createDatumMutation = graphql`
mutation DatumGraphCreateMutation(
$input: CreateDatumInput!
$connections: [ID!]!
) {
createDatum(input: $input) {
datumEdge @prependEdge(connections: $connections) {
node {
id
name
dataClassification
owner {
id
fullName
}
vendors(first: 10) {
edges {
node {
id
name
websiteUrl
}
}
}
createdAt
}
}
}
}
`;
export const updateDatumMutation = graphql`
mutation DatumGraphUpdateMutation($input: UpdateDatumInput!) {
updateDatum(input: $input) {
datum {
id
name
dataClassification
owner {
id
fullName
}
vendors(first: 50) {
edges {
node {
id
name
websiteUrl
}
}
}
updatedAt
}
}
}
`;
export const deleteDatumMutation = graphql`
mutation DatumGraphDeleteMutation(
$input: DeleteDatumInput!
$connections: [ID!]!
) {
deleteDatum(input: $input) {
deletedDatumId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteDatum = (
datum: { id?: string; name?: string },
connectionId: string
) => {
const [mutate] = useMutation(deleteDatumMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!datum.id || !datum.name) {
return alert(__("Failed to delete data: missing id or name"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
datumId: datum.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
'This will permanently delete "%s". This action cannot be undone.'
),
datum.name
),
}
);
};
};
export const useCreateDatum = (connectionId: string) => {
const [mutate] = useMutation(createDatumMutation);
const { __ } = useTranslate();
return (input: {
name: string;
dataClassification: string;
ownerId: string;
organizationId: string;
vendorIds?: string[];
}) => {
if (!input.name?.trim()) {
return alert(__("Failed to create data: name is required"));
}
if (!input.ownerId) {
return alert(__("Failed to create data: owner is required"));
}
if (!input.organizationId) {
return alert(__("Failed to create data: organization is required"));
}
return promisifyMutation(mutate)({
variables: {
input,
connections: [connectionId],
},
});
};
};
export const useUpdateDatum = () => {
const [mutate] = useMutation(updateDatumMutation);
const { __ } = useTranslate();
return (input: {
id: string;
name?: string;
dataClassification?: string;
ownerId?: string;
vendorIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update data: missing id"));
}
return promisifyMutation(mutate)({
variables: {
input,
},
});
};
};
export const useData = (queryRef: PreloadedQuery<DatumGraphListQuery>) => {
const data = usePreloadedQuery(dataQuery, queryRef);
return useMemo(() => {
const dataEntries = data.node?.data?.edges.map((edge) => edge.node) ?? [];
const connectionId = data.node?.data?.__id ?? "";
const peoples = data.node?.peoples?.edges.map((edge) => edge.node) ?? [];
return {
dataEntries,
connectionId,
peoples,
rawData: data,
};
}, [data]);
};

View File

@@ -0,0 +1,71 @@
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import type { DocumentGraphDeleteMutation } from "./__generated__/DocumentGraphDeleteMutation.graphql";
import type { DocumentGraphSendSigningNotificationsMutation } from "./__generated__/DocumentGraphSendSigningNotificationsMutation.graphql";
export const documentsQuery = graphql`
query DocumentGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...DocumentsPageListFragment
}
}
`;
export const DocumentsConnectionKey = "DocumentsPageFragment_documents";
const deleteDocumentMutation = graphql`
mutation DocumentGraphDeleteMutation(
$input: DeleteDocumentInput!
$connections: [ID!]!
) {
deleteDocument(input: $input) {
deletedDocumentId @deleteEdge(connections: $connections)
}
}
`;
export function useDeleteDocumentMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<DocumentGraphDeleteMutation>(
deleteDocumentMutation,
{
successMessage: __("Document deleted successfully."),
errorMessage: __("Failed to delete document. Please try again."),
}
);
}
const sendSigningNotificationsMutation = graphql`
mutation DocumentGraphSendSigningNotificationsMutation(
$input: SendSigningNotificationsInput!
) {
sendSigningNotifications(input: $input) {
success
}
}
`;
export function useSendSigningNotificationsMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<DocumentGraphSendSigningNotificationsMutation>(
sendSigningNotificationsMutation,
{
successMessage: __("Signing notifications sent successfully."),
errorMessage: __("Failed to send signing notifications. Please try again."),
}
);
}
export const documentNodeQuery = graphql`
query DocumentGraphNodeQuery($documentId: ID!) {
node(id: $documentId) {
... on Document {
...DocumentDetailPageDocumentFragment
}
}
}
`;

View File

@@ -0,0 +1,90 @@
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import { useCallback } from "react";
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
export const connectionListKey = "FrameworksListQuery_frameworks";
export const frameworksQuery = graphql`
query FrameworkGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
id
frameworks(first: 100)
@connection(key: "FrameworksListQuery_frameworks") {
__id
edges {
node {
id
...FrameworksPageCardFragment
}
}
}
}
}
}
`;
const deleteFrameworkMutation = graphql`
mutation FrameworkGraphDeleteMutation(
$input: DeleteFrameworkInput!
$connections: [ID!]!
) {
deleteFramework(input: $input) {
deletedFrameworkId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteFrameworkMutation = (
framework: { id: string; name: string },
connectionId: string
) => {
const [commitDelete] = useMutationWithToasts(deleteFrameworkMutation, {
errorMessage: "Failed to delete framework",
successMessage: "Framework deleted successfully",
});
const confirm = useConfirm();
const { __ } = useTranslate();
return useCallback(
(options?: { onSuccess?: () => void }) => {
return confirm(
() => {
return commitDelete({
variables: {
input: {
frameworkId: framework.id!,
},
connections: [connectionId],
},
...options,
});
},
{
message: sprintf(
__(
'This will permanently delete framework "%s". This action cannot be undone.'
),
framework.name
),
}
);
},
[framework, connectionId, commitDelete]
);
};
export const frameworkNodeQuery = graphql`
query FrameworkGraphNodeQuery($frameworkId: ID!) {
node(id: $frameworkId) {
... on Framework {
id
name
...FrameworkDetailPageFragment
}
}
}
`;

View File

@@ -0,0 +1,88 @@
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import type { MeasureGraphDeleteMutation } from "./__generated__/MeasureGraphDeleteMutation.graphql";
export const measuresQuery = graphql`
query MeasureGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...MeasuresPageFragment
}
}
`;
const deleteMeasureMutation = graphql`
mutation MeasureGraphDeleteMutation(
$input: DeleteMeasureInput!
$connections: [ID!]!
) {
deleteMeasure(input: $input) {
deletedMeasureId @deleteEdge(connections: $connections)
}
}
`;
export const MeasureConnectionKey = "MeasuresGraphListQuery__measures";
export function useDeleteMeasureMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<MeasureGraphDeleteMutation>(
deleteMeasureMutation,
{
successMessage: __("Measure deleted successfully."),
errorMessage: __("Failed to delete measure. Please try again."),
}
);
}
export const measureNodeQuery = graphql`
query MeasureGraphNodeQuery($measureId: ID!) {
node(id: $measureId) {
... on Measure {
id
name
description
state
category
evidencesInfos: evidences(first: 0) {
totalCount
}
risksInfos: risks(first: 0) {
totalCount
}
tasksInfos: tasks(first: 0) {
totalCount
}
controlsInfos: controls(first: 0) {
totalCount
}
...MeasureRisksTabFragment
...MeasureTasksTabFragment
...MeasureControlsTabFragment
...MeasureFormDialogMeasureFragment
...MeasureEvidencesTabFragment
}
}
}
`;
const measureUpdateMutation = graphql`
mutation MeasureGraphUpdateMutation($input: UpdateMeasureInput!) {
updateMeasure(input: $input) {
measure {
...MeasureFormDialogMeasureFragment
}
}
}
`;
export const useUpdateMeasure = () => {
const { __ } = useTranslate();
return useMutationWithToasts(measureUpdateMutation, {
successMessage: __("Measure updated successfully."),
errorMessage: __("Failed to update measure. Please try again."),
});
};

View File

@@ -0,0 +1,13 @@
import { graphql } from "relay-runtime";
export const organizationViewQuery = graphql`
query OrganizationGraph_ViewQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
name
...SettingsPageFragment
}
}
}
`;

View File

@@ -0,0 +1,179 @@
import { graphql } from "relay-runtime";
import type { PeopleGraphQuery } from "./__generated__/PeopleGraphQuery.graphql";
import {
useLazyLoadQuery,
useMutation,
usePreloadedQuery,
useRefetchableFragment,
type PreloadedQuery,
} from "react-relay";
import { useMemo } from "react";
import type { PeopleGraphPaginatedQuery } from "./__generated__/PeopleGraphPaginatedQuery.graphql";
import type { PeopleGraphPaginatedFragment$key } from "./__generated__/PeopleGraphPaginatedFragment.graphql";
import { useConfirm } from "@probo/ui";
import type { PeopleGraphDeleteMutation } from "./__generated__/PeopleGraphDeleteMutation.graphql";
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
const peopleQuery = graphql`
query PeopleGraphQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
peoples(first: 100, orderBy: { direction: ASC, field: CREATED_AT }) {
edges {
node {
id
fullName
primaryEmailAddress
}
}
}
}
}
}
`;
/**
* Return a list of people (used for people selectors)
*/
export function usePeople(organizationId: string) {
const data = useLazyLoadQuery<PeopleGraphQuery>(peopleQuery, {
organizationId: organizationId,
});
return useMemo(() => {
return data.organization?.peoples?.edges.map((edge) => edge.node) ?? [];
}, [data]);
}
export const paginatedPeopleQuery = graphql`
query PeopleGraphPaginatedQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
id
...PeopleGraphPaginatedFragment
}
}
}
`;
const paginatedPeopleFragment = graphql`
fragment PeopleGraphPaginatedFragment on Organization
@refetchable(queryName: "PeopleListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "PeopleOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
peoples(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "PeopleGraphPaginatedQuery_peoples") {
__id
edges {
node {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
}
`;
export function usePeopleQuery(
queryRef: PreloadedQuery<PeopleGraphPaginatedQuery>
) {
const data = usePreloadedQuery(paginatedPeopleQuery, queryRef);
const [dataFragment, refetch] = useRefetchableFragment(
paginatedPeopleFragment,
data.organization as PeopleGraphPaginatedFragment$key
);
const people = dataFragment?.peoples?.edges.map((edge) => edge.node);
return {
people,
refetch,
connectionId: dataFragment.peoples.__id,
};
}
export const deletePeopleMutation = graphql`
mutation PeopleGraphDeleteMutation(
$input: DeletePeopleInput!
$connections: [ID!]!
) {
deletePeople(input: $input) {
deletedPeopleId @deleteEdge(connections: $connections)
}
}
`;
export const PeopleConnectionKey = "PeopleGraphPaginatedQuery_peoples";
export const useDeletePeople = (
people: { id?: string; fullName?: string },
connectionId: string
) => {
const [mutate] = useMutation<PeopleGraphDeleteMutation>(deletePeopleMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!people.id || !people.fullName) {
return alert(__("Failed to delete people: missing id or fullName"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
peopleId: people.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
'This will permanently delete "%s". This action cannot be undone.'
),
people.fullName
),
}
);
};
};
export const peopleNodeQuery = graphql`
query PeopleGraphNodeQuery($peopleId: ID!) {
node(id: $peopleId) {
... on People {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
`;
export const updatePeopleMutation = graphql`
mutation PeopleGraphUpdateMutation($input: UpdatePeopleInput!) {
updatePeople(input: $input) {
people {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
`;

Some files were not shown because too many files have changed in this diff Show More