Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-19 14:18:14 +01:00
parent 026d939fb1
commit ef774ce6d3
8 changed files with 283 additions and 237 deletions

View File

@@ -1,11 +1,11 @@
import * as React from "react" import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast" import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react" import { X } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@@ -15,12 +15,12 @@ const ToastViewport = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( 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 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 data-[state=open]:sm:slide-in-from-bottom-full", "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 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 data-[state=open]:sm:slide-in-from-bottom-full",
@@ -35,8 +35,8 @@ const toastVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@@ -49,9 +49,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@@ -61,12 +61,12 @@ const ToastAction = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", "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-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className className,
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@@ -76,15 +76,15 @@ const ToastClose = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className,
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
@@ -95,8 +95,8 @@ const ToastTitle = React.forwardRef<
className={cn("text-sm font-semibold [&+div]:text-xs", className)} className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props} {...props}
/> />
)) ));
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
@@ -107,12 +107,12 @@ const ToastDescription = React.forwardRef<
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction> type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export {
type ToastProps, type ToastProps,
@@ -124,4 +124,4 @@ export {
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };

View File

@@ -1,4 +1,4 @@
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast";
import { import {
Toast, Toast,
ToastClose, ToastClose,
@@ -6,10 +6,10 @@ import {
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from "@/components/ui/toast" } from "@/components/ui/toast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
@@ -25,9 +25,9 @@ export function Toaster() {
{action} {action}
<ToastClose /> <ToastClose />
</Toast> </Toast>
) );
})} })}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) );
} }

View File

@@ -1,78 +1,75 @@
"use client" "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from "react";
import type { import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
@@ -80,27 +77,27 @@ export const reducer = (state: State, action: Action): State => {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t,
), ),
} };
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@@ -111,46 +108,46 @@ export const reducer = (state: State, action: Action): State => {
...t, ...t,
open: false, open: false,
} }
: t : t,
), ),
} };
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
} };
} }
} };
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
@@ -159,36 +156,36 @@ function toast({ ...props }: Toast) {
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
} };
} }
export { useToast, toast } export { useToast, toast };

View File

@@ -32,7 +32,10 @@ const createPeoplePageQuery = graphql`
`; `;
const createPeopleMutation = graphql` const createPeopleMutation = graphql`
mutation CreatePeoplePageCreatePeopleMutation($input: CreatePeopleInput!, $connections: [ID!]!) { mutation CreatePeoplePageCreatePeopleMutation(
$input: CreatePeopleInput!
$connections: [ID!]!
) {
createPeople(input: $input) { createPeople(input: $input) {
peopleEdge @prependEdge(connections: $connections) { peopleEdge @prependEdge(connections: $connections) {
node { node {
@@ -89,17 +92,18 @@ function CreatePeoplePageContent({
const navigate = useNavigate(); const navigate = useNavigate();
const environment = useRelayEnvironment(); const environment = useRelayEnvironment();
const data = usePreloadedQuery(createPeoplePageQuery, queryRef); const data = usePreloadedQuery(createPeoplePageQuery, queryRef);
const [createPeople] = useMutation<CreatePeoplePageCreatePeopleMutation>(createPeopleMutation); const [createPeople] =
useMutation<CreatePeoplePageCreatePeopleMutation>(createPeopleMutation);
const { toast } = useToast(); const { toast } = useToast();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
fullName: '', fullName: "",
primaryEmailAddress: '', primaryEmailAddress: "",
additionalEmailAddresses: [] as string[], additionalEmailAddresses: [] as string[],
kind: 'EMPLOYEE' as 'EMPLOYEE' | 'CONTRACTOR', kind: "EMPLOYEE" as "EMPLOYEE" | "CONTRACTOR",
}); });
const handleFieldChange = (field: keyof typeof formData, value: any) => { const handleFieldChange = (field: keyof typeof formData, value: any) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
[field]: value, [field]: value,
})); }));
@@ -107,7 +111,10 @@ function CreatePeoplePageContent({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const peopleConnectionId = ConnectionHandler.getConnectionID(data.currentOrganization.id, "PeopleListPage_peoples"); const peopleConnectionId = ConnectionHandler.getConnectionID(
data.currentOrganization.id,
"PeopleListPage_peoples",
);
createPeople({ createPeople({
variables: { variables: {
@@ -150,7 +157,7 @@ function CreatePeoplePageContent({
<EditableField <EditableField
label="Full Name" label="Full Name"
value={formData.fullName} value={formData.fullName}
onChange={(value) => handleFieldChange('fullName', value)} onChange={(value) => handleFieldChange("fullName", value)}
required required
/> />
@@ -158,7 +165,9 @@ function CreatePeoplePageContent({
label="Primary Email" label="Primary Email"
value={formData.primaryEmailAddress} value={formData.primaryEmailAddress}
type="email" type="email"
onChange={(value) => handleFieldChange('primaryEmailAddress', value)} onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
required required
/> />
@@ -174,17 +183,28 @@ function CreatePeoplePageContent({
type="email" type="email"
value={email} value={email}
onChange={(e) => { onChange={(e) => {
const newEmails = [...formData.additionalEmailAddresses]; const newEmails = [
...formData.additionalEmailAddresses,
];
newEmails[index] = e.target.value; newEmails[index] = e.target.value;
handleFieldChange('additionalEmailAddresses', newEmails); handleFieldChange(
"additionalEmailAddresses",
newEmails,
);
}} }}
/> />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
const newEmails = formData.additionalEmailAddresses.filter((_, i) => i !== index); const newEmails =
handleFieldChange('additionalEmailAddresses', newEmails); formData.additionalEmailAddresses.filter(
(_, i) => i !== index,
);
handleFieldChange(
"additionalEmailAddresses",
newEmails,
);
}} }}
> >
Remove Remove
@@ -195,7 +215,10 @@ function CreatePeoplePageContent({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
handleFieldChange('additionalEmailAddresses', [...formData.additionalEmailAddresses, '']); handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}} }}
> >
Add Email Add Email
@@ -206,7 +229,9 @@ function CreatePeoplePageContent({
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-lg font-medium">Additional Information</h2> <h2 className="text-lg font-medium">
Additional Information
</h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Additional details about the person Additional details about the person
</p> </p>
@@ -221,24 +246,24 @@ function CreatePeoplePageContent({
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => handleFieldChange('kind', 'EMPLOYEE')} onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === 'EMPLOYEE' formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2" ? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Employee Employee
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleFieldChange('kind', 'CONTRACTOR')} onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === 'CONTRACTOR' formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2" ? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Contractor Contractor
@@ -251,11 +276,7 @@ function CreatePeoplePageContent({
</div> </div>
</div> </div>
<div className="fixed bottom-6 right-6 flex gap-2"> <div className="fixed bottom-6 right-6 flex gap-2">
<Button <Button type="button" variant="outline" onClick={() => navigate(-1)}>
type="button"
variant="outline"
onClick={() => navigate(-1)}
>
Cancel Cancel
</Button> </Button>
<Button <Button

View File

@@ -55,10 +55,8 @@ function FrameworkCard({
return ( return (
<Card className="relative overflow-hidden border bg-card p-6"> <Card className="relative overflow-hidden border bg-card p-6">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="size-16"> <div className="size-16">{icon}</div>
{icon}
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold">{title}</h3> <h3 className="font-semibold">{title}</h3>
@@ -100,10 +98,10 @@ function FrameworkListPageContent({
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{frameworks.map((framework) => { {frameworks.map((framework) => {
const validatedControls = framework.controls.edges.filter( const validatedControls = framework.controls.edges.filter(
edge => edge?.node?.state === "IMPLEMENTED" (edge) => edge?.node?.state === "IMPLEMENTED",
).length; ).length;
const totalControls = framework.controls.edges.length; const totalControls = framework.controls.edges.length;
return ( return (
<Link key={framework.id} to={`/frameworks/${framework.id}`}> <Link key={framework.id} to={`/frameworks/${framework.id}`}>
<FrameworkCard <FrameworkCard
@@ -112,11 +110,13 @@ function FrameworkListPageContent({
icon={ icon={
<div className="flex size-full items-center justify-center rounded-full bg-blue-100"> <div className="flex size-full items-center justify-center rounded-full bg-blue-100">
<span className="text-lg font-semibold text-blue-900"> <span className="text-lg font-semibold text-blue-900">
{framework.name.split(' ')[0]} {framework.name.split(" ")[0]}
</span> </span>
</div> </div>
} }
status={validatedControls === totalControls ? "Compliant" : undefined} status={
validatedControls === totalControls ? "Compliant" : undefined
}
progress={ progress={
validatedControls === totalControls validatedControls === totalControls
? "All controls validated" ? "All controls validated"

View File

@@ -147,7 +147,8 @@ function PeopleListContent({
PeopleListPage_peoples$key PeopleListPage_peoples$key
>(peopleListFragment, data.currentOrganization); >(peopleListFragment, data.currentOrganization);
const peoples = peoplesConnection.peoples.edges.map((edge) => edge.node) ?? []; const peoples =
peoplesConnection.peoples.edges.map((edge) => edge.node) ?? [];
const pageInfo = peoplesConnection.peoples.pageInfo; const pageInfo = peoplesConnection.peoples.pageInfo;
return ( return (

View File

@@ -91,13 +91,15 @@ function PeopleOverviewPageContent({
const { setBreadcrumbSegment } = useBreadcrumb(); const { setBreadcrumbSegment } = useBreadcrumb();
const [editedFields, setEditedFields] = useState<Set<string>>(new Set()); const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
fullName: data.node.fullName || '', fullName: data.node.fullName || "",
primaryEmailAddress: data.node.primaryEmailAddress || '', primaryEmailAddress: data.node.primaryEmailAddress || "",
additionalEmailAddresses: data.node.additionalEmailAddresses || [], additionalEmailAddresses: data.node.additionalEmailAddresses || [],
kind: data.node.kind, kind: data.node.kind,
}); });
const [commit] = useMutation(updatePeopleMutation); const [commit] = useMutation(updatePeopleMutation);
const [_, loadQuery] = useQueryLoader<PeopleOverviewPageQueryType>(peopleOverviewPageQuery); const [_, loadQuery] = useQueryLoader<PeopleOverviewPageQueryType>(
peopleOverviewPageQuery,
);
const { toast } = useToast(); const { toast } = useToast();
const hasChanges = editedFields.size > 0; const hasChanges = editedFields.size > 0;
@@ -120,10 +122,11 @@ function PeopleOverviewPageContent({
setEditedFields(new Set()); setEditedFields(new Set());
}, },
onError: (error) => { onError: (error) => {
if (error.message?.includes('concurrent modification')) { if (error.message?.includes("concurrent modification")) {
toast({ toast({
title: "Error", title: "Error",
description: "Someone else modified this person. Reloading latest data.", description:
"Someone else modified this person. Reloading latest data.",
variant: "destructive", variant: "destructive",
}); });
loadQuery({ peopleId: data.node.id! }); loadQuery({ peopleId: data.node.id! });
@@ -139,17 +142,17 @@ function PeopleOverviewPageContent({
}, [commit, data.node.id, data.node.version, formData, loadQuery, toast]); }, [commit, data.node.id, data.node.version, formData, loadQuery, toast]);
const handleFieldChange = (field: keyof typeof formData, value: any) => { const handleFieldChange = (field: keyof typeof formData, value: any) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
[field]: value, [field]: value,
})); }));
setEditedFields(prev => new Set(prev).add(field)); setEditedFields((prev) => new Set(prev).add(field));
}; };
const handleCancel = () => { const handleCancel = () => {
setFormData({ setFormData({
fullName: data.node.fullName || '', fullName: data.node.fullName || "",
primaryEmailAddress: data.node.primaryEmailAddress || '', primaryEmailAddress: data.node.primaryEmailAddress || "",
additionalEmailAddresses: data.node.additionalEmailAddresses || [], additionalEmailAddresses: data.node.additionalEmailAddresses || [],
kind: data.node.kind, kind: data.node.kind,
}); });
@@ -169,14 +172,16 @@ function PeopleOverviewPageContent({
<EditableField <EditableField
label="Full Name" label="Full Name"
value={formData.fullName} value={formData.fullName}
onChange={(value) => handleFieldChange('fullName', value)} onChange={(value) => handleFieldChange("fullName", value)}
/> />
<EditableField <EditableField
label="Primary Email" label="Primary Email"
value={formData.primaryEmailAddress} value={formData.primaryEmailAddress}
type="email" type="email"
onChange={(value) => handleFieldChange('primaryEmailAddress', value)} onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
/> />
<div className="space-y-2"> <div className="space-y-2">
@@ -193,14 +198,17 @@ function PeopleOverviewPageContent({
onChange={(e) => { onChange={(e) => {
const newEmails = [...formData.additionalEmailAddresses]; const newEmails = [...formData.additionalEmailAddresses];
newEmails[index] = e.target.value; newEmails[index] = e.target.value;
handleFieldChange('additionalEmailAddresses', newEmails); handleFieldChange("additionalEmailAddresses", newEmails);
}} }}
/> />
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
const newEmails = formData.additionalEmailAddresses.filter((_, i) => i !== index); const newEmails =
handleFieldChange('additionalEmailAddresses', newEmails); formData.additionalEmailAddresses.filter(
(_, i) => i !== index,
);
handleFieldChange("additionalEmailAddresses", newEmails);
}} }}
> >
Remove Remove
@@ -210,7 +218,10 @@ function PeopleOverviewPageContent({
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
handleFieldChange('additionalEmailAddresses', [...formData.additionalEmailAddresses, '']); handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}} }}
> >
Add Email Add Email
@@ -234,24 +245,24 @@ function PeopleOverviewPageContent({
<Label className="text-sm">Kind</Label> <Label className="text-sm">Kind</Label>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleFieldChange('kind', 'EMPLOYEE')} onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === 'EMPLOYEE' formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2" ? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Employee Employee
</button> </button>
<button <button
onClick={() => handleFieldChange('kind', 'CONTRACTOR')} onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === 'CONTRACTOR' formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2" ? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Contractor Contractor
@@ -266,13 +277,10 @@ function PeopleOverviewPageContent({
{hasChanges && ( {hasChanges && (
<div className="fixed bottom-6 right-6 flex gap-2"> <div className="fixed bottom-6 right-6 flex gap-2">
<Button <Button variant="outline" onClick={handleCancel}>
variant="outline"
onClick={handleCancel}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >

View File

@@ -46,18 +46,18 @@ const vendorOverviewPageQuery = graphql`
const updateVendorMutation = graphql` const updateVendorMutation = graphql`
mutation VendorOverviewPageUpdateVendorMutation($input: UpdateVendorInput!) { mutation VendorOverviewPageUpdateVendorMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) { updateVendor(input: $input) {
id id
name name
description description
serviceStartAt serviceStartAt
serviceTerminationAt serviceTerminationAt
serviceCriticality serviceCriticality
riskTier riskTier
statusPageUrl statusPageUrl
termsOfServiceUrl termsOfServiceUrl
privacyPolicyUrl privacyPolicyUrl
updatedAt updatedAt
version version
} }
} }
`; `;
@@ -95,13 +95,13 @@ function EditableField({
// Format date for input field (YYYY-MM-DDTHH:mm) // Format date for input field (YYYY-MM-DDTHH:mm)
function formatDateForInput(date: string | null | undefined): string { function formatDateForInput(date: string | null | undefined): string {
if (!date) return ''; if (!date) return "";
return new Date(date).toISOString().slice(0, 16); return new Date(date).toISOString().slice(0, 16);
} }
// Format date for API (2006-01-02T15:04:05.999999999Z07:00) // Format date for API (2006-01-02T15:04:05.999999999Z07:00)
function formatDateForAPI(dateStr: string): string { function formatDateForAPI(dateStr: string): string {
if (!dateStr) return ''; if (!dateStr) return "";
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toISOString(); return date.toISOString();
} }
@@ -115,19 +115,21 @@ function VendorOverviewPageContent({
const { setBreadcrumbSegment } = useBreadcrumb(); const { setBreadcrumbSegment } = useBreadcrumb();
const [editedFields, setEditedFields] = useState<Set<string>>(new Set()); const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: data.node.name || '', name: data.node.name || "",
description: data.node.description || '', description: data.node.description || "",
// Format dates properly for datetime-local input // Format dates properly for datetime-local input
serviceStartAt: formatDateForInput(data.node.serviceStartAt), serviceStartAt: formatDateForInput(data.node.serviceStartAt),
serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt),
serviceCriticality: data.node.serviceCriticality, serviceCriticality: data.node.serviceCriticality,
riskTier: data.node.riskTier, riskTier: data.node.riskTier,
statusPageUrl: data.node.statusPageUrl || '', statusPageUrl: data.node.statusPageUrl || "",
termsOfServiceUrl: data.node.termsOfServiceUrl || '', termsOfServiceUrl: data.node.termsOfServiceUrl || "",
privacyPolicyUrl: data.node.privacyPolicyUrl || '', privacyPolicyUrl: data.node.privacyPolicyUrl || "",
}); });
const [commit] = useMutation(updateVendorMutation); const [commit] = useMutation(updateVendorMutation);
const [_, loadQuery] = useQueryLoader<VendorOverviewPageQueryType>(vendorOverviewPageQuery); const [_, loadQuery] = useQueryLoader<VendorOverviewPageQueryType>(
vendorOverviewPageQuery,
);
const { toast } = useToast(); const { toast } = useToast();
const hasChanges = editedFields.size > 0; const hasChanges = editedFields.size > 0;
@@ -136,7 +138,9 @@ function VendorOverviewPageContent({
const formattedData = { const formattedData = {
...formData, ...formData,
serviceStartAt: formatDateForAPI(formData.serviceStartAt), serviceStartAt: formatDateForAPI(formData.serviceStartAt),
serviceTerminationAt: formData.serviceTerminationAt ? formatDateForAPI(formData.serviceTerminationAt) : null, serviceTerminationAt: formData.serviceTerminationAt
? formatDateForAPI(formData.serviceTerminationAt)
: null,
}; };
commit({ commit({
@@ -156,15 +160,15 @@ function VendorOverviewPageContent({
setEditedFields(new Set()); setEditedFields(new Set());
}, },
onError: (error) => { onError: (error) => {
if (error.message?.includes('concurrent modification')) { if (error.message?.includes("concurrent modification")) {
toast({ toast({
title: "Error", title: "Error",
description: "Someone else modified this vendor. Reloading latest data.", description:
"Someone else modified this vendor. Reloading latest data.",
variant: "destructive", variant: "destructive",
}); });
loadQuery({ vendorId: data.node.id! }); loadQuery({ vendorId: data.node.id! });
} else { } else {
toast({ toast({
title: "Error", title: "Error",
@@ -180,25 +184,25 @@ function VendorOverviewPageContent({
}, [commit, data.node.id, data.node.version, formData, loadQuery, toast]); }, [commit, data.node.id, data.node.version, formData, loadQuery, toast]);
const handleFieldChange = (field: keyof typeof formData, value: any) => { const handleFieldChange = (field: keyof typeof formData, value: any) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
[field]: value, [field]: value,
})); }));
setEditedFields(prev => new Set(prev).add(field)); setEditedFields((prev) => new Set(prev).add(field));
}; };
// Update the cancel handler to also format dates // Update the cancel handler to also format dates
const handleCancel = () => { const handleCancel = () => {
setFormData({ setFormData({
name: data.node.name || '', name: data.node.name || "",
description: data.node.description || '', description: data.node.description || "",
serviceStartAt: formatDateForInput(data.node.serviceStartAt), serviceStartAt: formatDateForInput(data.node.serviceStartAt),
serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt),
serviceCriticality: data.node.serviceCriticality, serviceCriticality: data.node.serviceCriticality,
riskTier: data.node.riskTier, riskTier: data.node.riskTier,
statusPageUrl: data.node.statusPageUrl || '', statusPageUrl: data.node.statusPageUrl || "",
termsOfServiceUrl: data.node.termsOfServiceUrl || '', termsOfServiceUrl: data.node.termsOfServiceUrl || "",
privacyPolicyUrl: data.node.privacyPolicyUrl || '', privacyPolicyUrl: data.node.privacyPolicyUrl || "",
}); });
setEditedFields(new Set()); setEditedFields(new Set());
}; };
@@ -216,13 +220,13 @@ function VendorOverviewPageContent({
<EditableField <EditableField
label="Name" label="Name"
value={formData.name} value={formData.name}
onChange={(value) => handleFieldChange('name', value)} onChange={(value) => handleFieldChange("name", value)}
/> />
<EditableField <EditableField
label="Description" label="Description"
value={formData.description} value={formData.description}
onChange={(value) => handleFieldChange('description', value)} onChange={(value) => handleFieldChange("description", value)}
/> />
<Card className="p-6"> <Card className="p-6">
@@ -239,14 +243,18 @@ function VendorOverviewPageContent({
label="Service Start At" label="Service Start At"
value={formData.serviceStartAt} value={formData.serviceStartAt}
type="datetime-local" type="datetime-local"
onChange={(value) => handleFieldChange('serviceStartAt', value)} onChange={(value) =>
handleFieldChange("serviceStartAt", value)
}
/> />
<EditableField <EditableField
label="Service Termination At" label="Service Termination At"
value={formData.serviceTerminationAt} value={formData.serviceTerminationAt}
type="datetime-local" type="datetime-local"
onChange={(value) => handleFieldChange('serviceTerminationAt', value)} onChange={(value) =>
handleFieldChange("serviceTerminationAt", value)
}
/> />
<div className="space-y-2"> <div className="space-y-2">
@@ -255,46 +263,52 @@ function VendorOverviewPageContent({
<Label className="text-sm">Service Criticality</Label> <Label className="text-sm">Service Criticality</Label>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleFieldChange('serviceCriticality', 'LOW')} onClick={() =>
handleFieldChange("serviceCriticality", "LOW")
}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === 'LOW' formData.serviceCriticality === "LOW"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2" ? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Low Low
</button> </button>
<button <button
onClick={() => handleFieldChange('serviceCriticality', 'MEDIUM')} onClick={() =>
handleFieldChange("serviceCriticality", "MEDIUM")
}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === 'MEDIUM' formData.serviceCriticality === "MEDIUM"
? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2" ? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Medium Medium
</button> </button>
<button <button
onClick={() => handleFieldChange('serviceCriticality', 'HIGH')} onClick={() =>
handleFieldChange("serviceCriticality", "HIGH")
}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === 'HIGH' formData.serviceCriticality === "HIGH"
? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2" ? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
High High
</button> </button>
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{formData.serviceCriticality === 'HIGH' && {formData.serviceCriticality === "HIGH" &&
"Critical service - downtime severely impacts end-users"} "Critical service - downtime severely impacts end-users"}
{formData.serviceCriticality === 'MEDIUM' && {formData.serviceCriticality === "MEDIUM" &&
"Important service - downtime moderately affects end-users"} "Important service - downtime moderately affects end-users"}
{formData.serviceCriticality === 'LOW' && {formData.serviceCriticality === "LOW" &&
"Non-critical service - minimal end-user impact if down"} "Non-critical service - minimal end-user impact if down"}
</p> </p>
</div> </div>
@@ -305,46 +319,48 @@ function VendorOverviewPageContent({
<Label className="text-sm">Risk Tier</Label> <Label className="text-sm">Risk Tier</Label>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleFieldChange('riskTier', 'CRITICAL')} onClick={() => handleFieldChange("riskTier", "CRITICAL")}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === 'CRITICAL' formData.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2" ? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Critical Critical
</button> </button>
<button <button
onClick={() => handleFieldChange('riskTier', 'SIGNIFICANT')} onClick={() =>
handleFieldChange("riskTier", "SIGNIFICANT")
}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === 'SIGNIFICANT' formData.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2" ? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
Significant Significant
</button> </button>
<button <button
onClick={() => handleFieldChange('riskTier', 'GENERAL')} onClick={() => handleFieldChange("riskTier", "GENERAL")}
className={cn( className={cn(
"rounded-full px-4 py-1 text-sm transition-colors", "rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === 'GENERAL' formData.riskTier === "GENERAL"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2" ? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200" : "bg-gray-100 text-gray-900 hover:bg-gray-200",
)} )}
> >
General General
</button> </button>
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{formData.riskTier === 'CRITICAL' && {formData.riskTier === "CRITICAL" &&
"Handles sensitive data, critical for platform operation"} "Handles sensitive data, critical for platform operation"}
{formData.riskTier === 'SIGNIFICANT' && {formData.riskTier === "SIGNIFICANT" &&
"No user data access, but important for platform management"} "No user data access, but important for platform management"}
{formData.riskTier === 'GENERAL' && {formData.riskTier === "GENERAL" &&
"General vendor with minimal risk"} "General vendor with minimal risk"}
</p> </p>
</div> </div>
@@ -352,19 +368,25 @@ function VendorOverviewPageContent({
<EditableField <EditableField
label="Status Page URL" label="Status Page URL"
value={formData.statusPageUrl || ""} value={formData.statusPageUrl || ""}
onChange={(value) => handleFieldChange('statusPageUrl', value)} onChange={(value) =>
handleFieldChange("statusPageUrl", value)
}
/> />
<EditableField <EditableField
label="Terms of Service URL" label="Terms of Service URL"
value={formData.termsOfServiceUrl || ""} value={formData.termsOfServiceUrl || ""}
onChange={(value) => handleFieldChange('termsOfServiceUrl', value)} onChange={(value) =>
handleFieldChange("termsOfServiceUrl", value)
}
/> />
<EditableField <EditableField
label="Privacy Policy URL" label="Privacy Policy URL"
value={formData.privacyPolicyUrl || ""} value={formData.privacyPolicyUrl || ""}
onChange={(value) => handleFieldChange('privacyPolicyUrl', value)} onChange={(value) =>
handleFieldChange("privacyPolicyUrl", value)
}
/> />
</div> </div>
</div> </div>
@@ -374,13 +396,10 @@ function VendorOverviewPageContent({
{hasChanges && ( {hasChanges && (
<div className="fixed bottom-6 right-6 flex gap-2"> <div className="fixed bottom-6 right-6 flex gap-2">
<Button <Button variant="outline" onClick={handleCancel}>
variant="outline"
onClick={handleCancel}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >