Fix all lazy suspense callbacks

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-03-21 17:57:47 +04:00
parent 961a460b55
commit 5bd1999799
74 changed files with 4373 additions and 4257 deletions

View File

@@ -1,87 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import {
graphql,
ConnectionHandler,
useMutation,
useLazyLoadQuery,
} from "react-relay";
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { CreateOrganizationPageCreateOrganizationMutation } from "./__generated__/CreateOrganizationPageCreateOrganizationMutation.graphql";
import { CreateOrganizationPageViewerQuery } from "./__generated__/CreateOrganizationPageViewerQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "./ErrorBoundary";
const createOrganizationMutation = graphql`
mutation CreateOrganizationPageCreateOrganizationMutation(
$input: CreateOrganizationInput!
$connections: [ID!]!
) {
createOrganization(input: $input) {
organizationEdge @appendEdge(connections: $connections) {
node {
id
name
logoUrl
}
}
}
}
`;
const viewerQuery = graphql`
query CreateOrganizationPageViewerQuery {
viewer {
id
}
}
`;
function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">{label}</Label>
</div>
<div className="space-y-2">
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
/>
{helpText && <p className="text-sm text-gray-500">{helpText}</p>}
</div>
</div>
);
}
export function CreateOrganizationPageSkeleton() {
export function CreateOrganizationViewSkeleton() {
return (
<PageTemplateSkeleton
title="Create Organization"
@@ -92,90 +14,19 @@ export function CreateOrganizationPageSkeleton() {
);
}
export default function CreateOrganizationPage() {
const navigate = useNavigate();
const { toast } = useToast();
const data = useLazyLoadQuery<CreateOrganizationPageViewerQuery>(
viewerQuery,
{}
);
const [createOrganization] =
useMutation<CreateOrganizationPageCreateOrganizationMutation>(
createOrganizationMutation
);
const [formData, setFormData] = useState({
name: "",
});
const CreateOrganizationView = lazy(() => import("./CreateOrganizationView"));
const handleFieldChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createOrganization({
variables: {
input: {
name: formData.name,
},
connections: [
ConnectionHandler.getConnectionID(
data.viewer.id,
"OrganizationSwitcher_organizations"
),
],
},
onCompleted: (response) => {
const newOrg = response.createOrganization.organizationEdge.node;
toast({
title: "Success",
description: "Organization created successfully",
variant: "default",
});
navigate(`/organizations/${newOrg.id}`);
},
onError: (error) => {
toast({
title: "Error",
description: error.message || "Failed to create organization",
variant: "destructive",
});
},
});
};
export function CreateOrganizationPage() {
const location = useLocation();
return (
<PageTemplate
title="Create Organization"
description="Create a new organization to manage your compliance and security needs."
<Suspense
key={location.pathname}
fallback={<CreateOrganizationViewSkeleton />}
>
<form onSubmit={handleSubmit}>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Organization Details</CardTitle>
<CardDescription>
Enter the basic information about your organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<EditableField
label="Organization Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
helpText="The name of your organization as it will appear throughout the platform."
required
/>
<Button type="submit" className="w-full">
Create Organization
</Button>
</CardContent>
</Card>
</form>
</PageTemplate>
<ErrorBoundaryWithLocation>
<CreateOrganizationView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,170 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import {
graphql,
ConnectionHandler,
useMutation,
useLazyLoadQuery,
} from "react-relay";
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { CreateOrganizationViewCreateOrganizationMutation } from "./__generated__/CreateOrganizationViewCreateOrganizationMutation.graphql";
import { CreateOrganizationViewViewerQuery } from "./__generated__/CreateOrganizationViewViewerQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
const createOrganizationMutation = graphql`
mutation CreateOrganizationViewCreateOrganizationMutation(
$input: CreateOrganizationInput!
$connections: [ID!]!
) {
createOrganization(input: $input) {
organizationEdge @appendEdge(connections: $connections) {
node {
id
name
logoUrl
}
}
}
}
`;
const viewerQuery = graphql`
query CreateOrganizationViewViewerQuery {
viewer {
id
}
}
`;
function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">{label}</Label>
</div>
<div className="space-y-2">
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
/>
{helpText && <p className="text-sm text-gray-500">{helpText}</p>}
</div>
</div>
);
}
export default function CreateOrganizationView() {
const navigate = useNavigate();
const { toast } = useToast();
const data = useLazyLoadQuery<CreateOrganizationViewViewerQuery>(
viewerQuery,
{}
);
const [createOrganization] =
useMutation<CreateOrganizationViewCreateOrganizationMutation>(
createOrganizationMutation
);
const [formData, setFormData] = useState({
name: "",
});
const handleFieldChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createOrganization({
variables: {
input: {
name: formData.name,
},
connections: [
ConnectionHandler.getConnectionID(
data.viewer.id,
"OrganizationSwitcher_organizations"
),
],
},
onCompleted: (response) => {
const newOrg = response.createOrganization.organizationEdge.node;
toast({
title: "Success",
description: "Organization created successfully",
variant: "default",
});
navigate(`/organizations/${newOrg.id}`);
},
onError: (error) => {
toast({
title: "Error",
description: error.message || "Failed to create organization",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Organization"
description="Create a new organization to manage your compliance and security needs."
>
<form onSubmit={handleSubmit}>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Organization Details</CardTitle>
<CardDescription>
Enter the basic information about your organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<EditableField
label="Organization Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
helpText="The name of your organization as it will appear throughout the platform."
required
/>
<Button type="submit" className="w-full">
Create Organization
</Button>
</CardContent>
</Card>
</form>
</PageTemplate>
);
}

View File

@@ -1,227 +1,67 @@
import { lazy, Suspense } from "react";
import { Route, Routes } from "react-router";
import { CreateOrganizationPageSkeleton } from "./CreateOrganizationPage";
import { ControlOverviewPageSkeleton } from "./frameworks/controls/ControlOverviewPage";
import { CreateControlPageSkeleton } from "./frameworks/controls/CreateControlPage";
import { UpdateControlPageSkeleton } from "./frameworks/controls/UpdateControlPage";
import { CreateFrameworkPageSkeleton } from "./frameworks/CreateFrameworkPage";
import { UpdateFrameworkPageSkeleton } from "./frameworks/UpdateFrameworkPage";
import "./HomePage";
import { CreatePeoplePageSkeleton } from "./people/CreatePeoplePage";
import { PeopleListPageSkeleton } from "./people/PeopleListPage";
import { PeopleOverviewPageSkeleton } from "./people/PeopleOverviewPage";
import { CreatePolicyPageSkeleton } from "./policies/CreatePolicyPage";
import { PolicyListPageSkeleton } from "./policies/PolicyListPage";
import { PolicyOverviewPageSkeleton } from "./policies/PolicyOverviewPage";
import { UpdatePolicyPageSkeleton } from "./policies/UpdatePolicyPage";
import { SettingsPageSkeleton } from "./SettingsPage";
import { VendorListPage } from "./vendors/VendorListPage";
import { VendorOverviewPageSkeleton } from "./vendors/VendorOverviewPage";
import { ErrorBoundaryWithLocation } from "./ErrorBoundary";
import OrganizationLayout from "./OrganizationLayout";
import NoOrganizationLayout from "./NoOrganizationLayout";
import { FrameworkListPage } from "./frameworks/FrameworkListPage";
import { FrameworkPage } from "./frameworks/FrameworkPage";
const CreateOrganizationPage = lazy(() => import("./CreateOrganizationPage"));
const ControlOverviewPage = lazy(
() => import("./frameworks/controls/ControlOverviewPage")
);
const CreateControlPage = lazy(
() => import("./frameworks/controls/CreateControlPage")
);
const UpdateControlPage = lazy(
() => import("./frameworks/controls/UpdateControlPage")
);
const CreateFrameworkPage = lazy(
() => import("./frameworks/CreateFrameworkPage")
);
const UpdateFrameworkPage = lazy(
() => import("./frameworks/UpdateFrameworkPage")
);
const HomePage = lazy(() => import("./HomePage"));
const CreatePeoplePage = lazy(() => import("./people/CreatePeoplePage"));
const PeopleListPage = lazy(() => import("./people/PeopleListPage"));
const PeopleOverviewPage = lazy(() => import("./people/PeopleOverviewPage"));
const CreatePolicyPage = lazy(() => import("./policies/CreatePolicyPage"));
const PolicyListPage = lazy(() => import("./policies/PolicyListPage"));
const PolicyOverviewPage = lazy(() => import("./policies/PolicyOverviewPage"));
const UpdatePolicyPage = lazy(() => import("./policies/UpdatePolicyPage"));
const SettingsPage = lazy(() => import("./SettingsPage"));
const VendorOverviewPage = lazy(() => import("./vendors/VendorOverviewPage"));
import { PeopleListPage } from "./people/PeopleListPage";
import { CreatePeoplePage } from "./people/CreatePeoplePage";
import { PeoplePage } from "./people/PeoplePage";
import { CreateFrameworkPage } from "./frameworks/CreateFrameworkPage";
import { UpdateFrameworkPage } from "./frameworks/UpdateFrameworkPage";
import { CreateControlPage } from "./frameworks/controls/CreateControlPage";
import { ControlPage } from "./frameworks/controls/ControlPage";
import { UpdateControlPage } from "./frameworks/controls/UpdateControlPage";
import { VendorPage } from "./vendors/VendorPage";
import { PolicyListPage } from "./policies/PolicyListPage";
import { CreatePolicyPage } from "./policies/CreatePolicyPage";
import { PolicyPage } from "./policies/PolicyPage";
import { UpdatePolicyPage } from "./policies/UpdatePolicyPage";
import { SettingsPage } from "./SettingsPage";
import { CreateOrganizationPage } from "./CreateOrganizationPage";
import HomePage from "./HomePage";
export function OrganizationsRoutes() {
return (
<Routes>
<Route path=":organizationId/*" element={<OrganizationLayout />}>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<HomePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="people"
element={
<Suspense fallback={<PeopleListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PeopleListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="people/create"
element={
<Suspense fallback={<CreatePeoplePageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreatePeoplePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="people/:peopleId"
element={
<Suspense fallback={<PeopleOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PeopleOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route index element={<HomePage />} />
<Route path="people" element={<PeopleListPage />} />
<Route path="people/create" element={<CreatePeoplePage />} />
<Route path="people/:peopleId" element={<PeoplePage />} />
<Route path="vendors" element={<VendorListPage />} />
<Route path="frameworks" element={<FrameworkListPage />} />
<Route
path="frameworks/create"
element={
<Suspense fallback={<CreateFrameworkPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route path="frameworks/create" element={<CreateFrameworkPage />} />
<Route path="frameworks/:frameworkId" element={<FrameworkPage />} />
<Route
path="frameworks/:frameworkId/update"
element={
<Suspense fallback={<UpdateFrameworkPageSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
element={<UpdateFrameworkPage />}
/>
<Route
path="frameworks/:frameworkId/controls/create"
element={
<Suspense fallback={<CreateControlPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
element={<CreateControlPage />}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense fallback={<ControlOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
element={<ControlPage />}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId/update"
element={
<Suspense fallback={<UpdateControlPageSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense fallback={<VendorOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
{/* Policy Routes */}
<Route
path="policies"
element={
<Suspense fallback={<PolicyListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PolicyListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/create"
element={
<Suspense fallback={<CreatePolicyPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreatePolicyPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/:policyId"
element={
<Suspense fallback={<PolicyOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PolicyOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
element={<UpdateControlPage />}
/>
<Route path="vendors/:vendorId" element={<VendorPage />} />
<Route path="policies" element={<PolicyListPage />} />
<Route path="policies/create" element={<CreatePolicyPage />} />
<Route path="policies/:policyId" element={<PolicyPage />} />
<Route
path="policies/:policyId/update"
element={
<Suspense fallback={<UpdatePolicyPageSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdatePolicyPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense fallback={<SettingsPageSkeleton />}>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
element={<UpdatePolicyPage />}
/>
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NoOrganizationLayout />}>
<Route
path="create"
element={
<Suspense fallback={<CreateOrganizationPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateOrganizationPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route path="create" element={<CreateOrganizationPage />} />
</Route>
</Routes>
);

View File

@@ -1,525 +1,11 @@
import { Building2, Upload, MoreVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Suspense, useEffect, useState, useRef } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
} from "react-relay";
import { useParams } from "react-router";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { SettingsPageQuery as SettingsPageQueryType } from "./__generated__/SettingsPageQuery.graphql";
import type { SettingsPageUpdateOrganizationMutation as SettingsPageUpdateOrganizationMutationType } from "./__generated__/SettingsPageUpdateOrganizationMutation.graphql";
import type { SettingsPageInviteUserMutation as SettingsPageInviteUserMutationType } from "./__generated__/SettingsPageInviteUserMutation.graphql";
import type { SettingsPageRemoveUserMutation as SettingsPageRemoveUserMutationType } from "./__generated__/SettingsPageRemoveUserMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "./ErrorBoundary";
const settingsPageQuery = graphql`
query SettingsPageQuery($organizationID: ID!) {
organization: node(id: $organizationID) {
id
... on Organization {
name
logoUrl
users(first: 100) {
edges {
node {
id
fullName
email
createdAt
}
}
}
}
}
}
`;
const SettingsView = lazy(() => import("./SettingsView"));
const updateOrganizationMutation = graphql`
mutation SettingsPageUpdateOrganizationMutation(
$input: UpdateOrganizationInput!
) {
updateOrganization(input: $input) {
organization {
id
name
logoUrl
}
}
}
`;
const inviteUserMutation = graphql`
mutation SettingsPageInviteUserMutation($input: InviteUserInput!) {
inviteUser(input: $input) {
success
}
}
`;
const removeUserMutation = graphql`
mutation SettingsPageRemoveUserMutation($input: RemoveUserInput!) {
removeUser(input: $input) {
success
}
}
`;
function SettingsPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<SettingsPageQueryType>;
}) {
const data = usePreloadedQuery(settingsPageQuery, queryRef);
const organization = data.organization;
const users = organization.users?.edges.map((edge) => edge.node) || [];
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteFullName, setInviteFullName] = useState("");
const [isInviting, setIsInviting] = useState(false);
const [organizationName, setOrganizationName] = useState(
organization.name || ""
);
const [isUploading, setIsUploading] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [updateOrganization] =
useMutation<SettingsPageUpdateOrganizationMutationType>(
updateOrganizationMutation
);
const [inviteUser] =
useMutation<SettingsPageInviteUserMutationType>(inviteUserMutation);
const [removeUser] =
useMutation<SettingsPageRemoveUserMutationType>(removeUserMutation);
const { organizationId } = useParams();
const [, loadQuery] =
useQueryLoader<SettingsPageQueryType>(settingsPageQuery);
const handleUpdateName = () => {
updateOrganization({
variables: {
input: {
organizationId: organization.id,
name: organizationName,
},
},
onCompleted: () => {
toast({
title: "Organization updated",
description: "Organization name has been updated successfully.",
variant: "default",
});
setIsEditNameOpen(false);
},
onError: (error) => {
toast({
title: "Error updating organization",
description: error.message,
variant: "destructive",
});
},
});
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Create a FileReader to read the file as a data URL
const reader = new FileReader();
reader.onloadend = () => {
setIsUploading(true);
updateOrganization({
variables: {
input: {
organizationId: organization.id,
logo: null,
},
},
uploadables: {
"input.logo": file,
},
onCompleted: () => {
setIsUploading(false);
toast({
title: "Logo updated",
description: "Organization logo has been updated successfully.",
variant: "default",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
onError: (error) => {
setIsUploading(false);
toast({
title: "Error updating logo",
description: error.message,
variant: "destructive",
});
},
});
};
reader.readAsDataURL(file);
};
const handleInviteMember = () => {
if (!inviteEmail || !inviteFullName) {
toast({
title: "Missing information",
description:
"Please provide both email and full name for the invitation",
variant: "destructive",
});
return;
}
setIsInviting(true);
inviteUser({
variables: {
input: {
organizationId: organization.id,
email: inviteEmail,
fullName: inviteFullName,
},
},
onCompleted: (response) => {
setIsInviting(false);
if (response.inviteUser?.success) {
toast({
title: "Invitation sent",
description: `An invitation has been sent to ${inviteEmail}`,
variant: "default",
});
setIsInviteOpen(false);
setInviteEmail("");
setInviteFullName("");
} else {
toast({
title: "Error sending invitation",
description: "The invitation could not be sent. Please try again.",
variant: "destructive",
});
}
},
onError: (error) => {
setIsInviting(false);
toast({
title: "Error sending invitation",
description: error.message,
variant: "destructive",
});
},
});
};
const handleRemoveUser = (userId: string) => {
setIsRemoving(true);
removeUser({
variables: {
input: {
organizationId: organization.id,
userId: userId,
},
},
onCompleted: (response) => {
setIsRemoving(false);
if (response.removeUser?.success) {
toast({
title: "User removed",
description: "The user has been removed from the organization.",
variant: "default",
});
// Refresh the query to update the UI
loadQuery({ organizationID: organizationId! });
} else {
toast({
title: "Error removing user",
description: "The user could not be removed. Please try again.",
variant: "destructive",
});
}
},
onError: (error) => {
setIsRemoving(false);
toast({
title: "Error removing user",
description: error.message,
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Settings"
description="Manage your details and personal preferences here"
>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Organization information</CardTitle>
<CardDescription>Manage your organization details</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium">Organization logo</label>
<div className="flex items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="flex items-center gap-3">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt="Logo"
className="h-10 w-10 rounded-lg object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border">
<Upload className="h-5 w-5 text-muted-foreground" />
</div>
)}
<span className="text-muted-foreground">
Upload a logo to be displayed at the top of your trust page
</span>
</div>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
id="logo-upload"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? "Uploading..." : "Change image"}
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organization name</label>
<div className="flex items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
<span className="text-muted-foreground">
Set the name of the organization
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">{organization.name}</span>
<Button
variant="outline"
size="sm"
onClick={() => {
setOrganizationName(organization.name || "");
setIsEditNameOpen(true);
}}
>
Edit
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Workspace members</CardTitle>
<CardDescription>
Manage who has privileged access to your workspace and their
permissions.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsInviteOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Invite member
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-muted p-3">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-medium">No members found</h3>
<p className="mt-2 text-sm text-muted-foreground">
You haven&apos;t added any members to your workspace yet.
</p>
</div>
) : (
users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg border p-3 shadow-xs"
>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{user.fullName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.fullName}
</span>
<span className="text-sm text-muted-foreground">
{user.email}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Owner
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600"
onClick={() => handleRemoveUser(user.id)}
disabled={isRemoving}
>
{isRemoving ? "Removing..." : "Remove member"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
<Dialog open={isEditNameOpen} onOpenChange={setIsEditNameOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Organization Name</DialogTitle>
<DialogDescription>
Update the name of your organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="organization-name">Organization Name</Label>
<Input
id="organization-name"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Enter organization name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditNameOpen(false)}>
Cancel
</Button>
<Button onClick={handleUpdateName}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your workspace.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fullName">Full Name</Label>
<Input
id="fullName"
type="text"
value={inviteFullName}
onChange={(e) => setInviteFullName(e.target.value)}
placeholder="Enter full name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsInviteOpen(false)}>
Cancel
</Button>
<Button onClick={handleInviteMember} disabled={isInviting}>
{isInviting ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageTemplate>
);
}
export function SettingsPageSkeleton() {
export function SettingsViewSkeleton() {
return (
<PageTemplateSkeleton
title="Settings"
@@ -540,23 +26,14 @@ export function SettingsPageSkeleton() {
);
}
export default function SettingsPage() {
const [queryRef, loadQuery] =
useQueryLoader<SettingsPageQueryType>(settingsPageQuery);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationID: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <SettingsPageSkeleton />;
}
export function SettingsPage() {
const location = useLocation();
return (
<Suspense fallback={<SettingsPageSkeleton />}>
<SettingsPageContent queryRef={queryRef} />
<Suspense key={location.pathname} fallback={<SettingsViewSkeleton />}>
<ErrorBoundaryWithLocation>
<SettingsView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,542 @@
import { Building2, Upload, MoreVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Suspense, useEffect, useState, useRef } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
} from "react-relay";
import { useParams } from "react-router";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { SettingsViewQuery as SettingsViewQueryType } from "./__generated__/SettingsViewQuery.graphql";
import type { SettingsViewUpdateOrganizationMutation as SettingsViewUpdateOrganizationMutationType } from "./__generated__/SettingsViewUpdateOrganizationMutation.graphql";
import type { SettingsViewInviteUserMutation as SettingsViewInviteUserMutationType } from "./__generated__/SettingsViewInviteUserMutation.graphql";
import type { SettingsViewRemoveUserMutation as SettingsViewRemoveUserMutationType } from "./__generated__/SettingsViewRemoveUserMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { SettingsViewSkeleton } from "./SettingsPage";
const settingsViewQuery = graphql`
query SettingsViewQuery($organizationID: ID!) {
organization: node(id: $organizationID) {
id
... on Organization {
name
logoUrl
users(first: 100) {
edges {
node {
id
fullName
email
createdAt
}
}
}
}
}
}
`;
const updateOrganizationMutation = graphql`
mutation SettingsViewUpdateOrganizationMutation(
$input: UpdateOrganizationInput!
) {
updateOrganization(input: $input) {
organization {
id
name
logoUrl
}
}
}
`;
const inviteUserMutation = graphql`
mutation SettingsViewInviteUserMutation($input: InviteUserInput!) {
inviteUser(input: $input) {
success
}
}
`;
const removeUserMutation = graphql`
mutation SettingsViewRemoveUserMutation($input: RemoveUserInput!) {
removeUser(input: $input) {
success
}
}
`;
function SettingsViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<SettingsViewQueryType>;
}) {
const data = usePreloadedQuery(settingsViewQuery, queryRef);
const organization = data.organization;
const users = organization.users?.edges.map((edge) => edge.node) || [];
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteFullName, setInviteFullName] = useState("");
const [isInviting, setIsInviting] = useState(false);
const [organizationName, setOrganizationName] = useState(
organization.name || ""
);
const [isUploading, setIsUploading] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [updateOrganization] =
useMutation<SettingsViewUpdateOrganizationMutationType>(
updateOrganizationMutation
);
const [inviteUser] =
useMutation<SettingsViewInviteUserMutationType>(inviteUserMutation);
const [removeUser] =
useMutation<SettingsViewRemoveUserMutationType>(removeUserMutation);
const { organizationId } = useParams();
const [, loadQuery] =
useQueryLoader<SettingsViewQueryType>(settingsViewQuery);
const handleUpdateName = () => {
updateOrganization({
variables: {
input: {
organizationId: organization.id,
name: organizationName,
},
},
onCompleted: () => {
toast({
title: "Organization updated",
description: "Organization name has been updated successfully.",
variant: "default",
});
setIsEditNameOpen(false);
},
onError: (error) => {
toast({
title: "Error updating organization",
description: error.message,
variant: "destructive",
});
},
});
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Create a FileReader to read the file as a data URL
const reader = new FileReader();
reader.onloadend = () => {
setIsUploading(true);
updateOrganization({
variables: {
input: {
organizationId: organization.id,
logo: null,
},
},
uploadables: {
"input.logo": file,
},
onCompleted: () => {
setIsUploading(false);
toast({
title: "Logo updated",
description: "Organization logo has been updated successfully.",
variant: "default",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
onError: (error) => {
setIsUploading(false);
toast({
title: "Error updating logo",
description: error.message,
variant: "destructive",
});
},
});
};
reader.readAsDataURL(file);
};
const handleInviteMember = () => {
if (!inviteEmail || !inviteFullName) {
toast({
title: "Missing information",
description:
"Please provide both email and full name for the invitation",
variant: "destructive",
});
return;
}
setIsInviting(true);
inviteUser({
variables: {
input: {
organizationId: organization.id,
email: inviteEmail,
fullName: inviteFullName,
},
},
onCompleted: (response) => {
setIsInviting(false);
if (response.inviteUser?.success) {
toast({
title: "Invitation sent",
description: `An invitation has been sent to ${inviteEmail}`,
variant: "default",
});
setIsInviteOpen(false);
setInviteEmail("");
setInviteFullName("");
} else {
toast({
title: "Error sending invitation",
description: "The invitation could not be sent. Please try again.",
variant: "destructive",
});
}
},
onError: (error) => {
setIsInviting(false);
toast({
title: "Error sending invitation",
description: error.message,
variant: "destructive",
});
},
});
};
const handleRemoveUser = (userId: string) => {
setIsRemoving(true);
removeUser({
variables: {
input: {
organizationId: organization.id,
userId: userId,
},
},
onCompleted: (response) => {
setIsRemoving(false);
if (response.removeUser?.success) {
toast({
title: "User removed",
description: "The user has been removed from the organization.",
variant: "default",
});
// Refresh the query to update the UI
loadQuery({ organizationID: organizationId! });
} else {
toast({
title: "Error removing user",
description: "The user could not be removed. Please try again.",
variant: "destructive",
});
}
},
onError: (error) => {
setIsRemoving(false);
toast({
title: "Error removing user",
description: error.message,
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Settings"
description="Manage your details and personal preferences here"
>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Organization information</CardTitle>
<CardDescription>Manage your organization details</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium">Organization logo</label>
<div className="flex items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="flex items-center gap-3">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt="Logo"
className="h-10 w-10 rounded-lg object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border">
<Upload className="h-5 w-5 text-muted-foreground" />
</div>
)}
<span className="text-muted-foreground">
Upload a logo to be displayed at the top of your trust page
</span>
</div>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
id="logo-upload"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? "Uploading..." : "Change image"}
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organization name</label>
<div className="flex items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
<span className="text-muted-foreground">
Set the name of the organization
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">{organization.name}</span>
<Button
variant="outline"
size="sm"
onClick={() => {
setOrganizationName(organization.name || "");
setIsEditNameOpen(true);
}}
>
Edit
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Workspace members</CardTitle>
<CardDescription>
Manage who has privileged access to your workspace and their
permissions.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsInviteOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Invite member
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-muted p-3">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-medium">No members found</h3>
<p className="mt-2 text-sm text-muted-foreground">
You haven&apos;t added any members to your workspace yet.
</p>
</div>
) : (
users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg border p-3 shadow-xs"
>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{user.fullName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.fullName}
</span>
<span className="text-sm text-muted-foreground">
{user.email}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Owner
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600"
onClick={() => handleRemoveUser(user.id)}
disabled={isRemoving}
>
{isRemoving ? "Removing..." : "Remove member"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
<Dialog open={isEditNameOpen} onOpenChange={setIsEditNameOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Organization Name</DialogTitle>
<DialogDescription>
Update the name of your organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="organization-name">Organization Name</Label>
<Input
id="organization-name"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Enter organization name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditNameOpen(false)}>
Cancel
</Button>
<Button onClick={handleUpdateName}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your workspace.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fullName">Full Name</Label>
<Input
id="fullName"
type="text"
value={inviteFullName}
onChange={(e) => setInviteFullName(e.target.value)}
placeholder="Enter full name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsInviteOpen(false)}>
Cancel
</Button>
<Button onClick={handleInviteMember} disabled={isInviting}>
{isInviting ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageTemplate>
);
}
export default function SettingsView() {
const [queryRef, loadQuery] =
useQueryLoader<SettingsViewQueryType>(settingsViewQuery);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationID: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <SettingsViewSkeleton />;
}
return (
<Suspense fallback={<SettingsViewSkeleton />}>
<SettingsViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<a147a01f27db2e92570cd66f6b9c1df9>>
* @generated SignedSource<<d5934a25e82e73935b121d9f456b41d5>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,11 +12,11 @@ import { ConcreteRequest } from 'relay-runtime';
export type CreateOrganizationInput = {
name: string;
};
export type CreateOrganizationPageCreateOrganizationMutation$variables = {
export type CreateOrganizationViewCreateOrganizationMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateOrganizationInput;
};
export type CreateOrganizationPageCreateOrganizationMutation$data = {
export type CreateOrganizationViewCreateOrganizationMutation$data = {
readonly createOrganization: {
readonly organizationEdge: {
readonly node: {
@@ -27,9 +27,9 @@ export type CreateOrganizationPageCreateOrganizationMutation$data = {
};
};
};
export type CreateOrganizationPageCreateOrganizationMutation = {
response: CreateOrganizationPageCreateOrganizationMutation$data;
variables: CreateOrganizationPageCreateOrganizationMutation$variables;
export type CreateOrganizationViewCreateOrganizationMutation = {
response: CreateOrganizationViewCreateOrganizationMutation$data;
variables: CreateOrganizationViewCreateOrganizationMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -101,7 +101,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "CreateOrganizationPageCreateOrganizationMutation",
"name": "CreateOrganizationViewCreateOrganizationMutation",
"selections": [
{
"alias": null,
@@ -126,7 +126,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreateOrganizationPageCreateOrganizationMutation",
"name": "CreateOrganizationViewCreateOrganizationMutation",
"selections": [
{
"alias": null,
@@ -159,16 +159,16 @@ return {
]
},
"params": {
"cacheID": "aa445272c59c25a9a2edc7592c1fd7cc",
"cacheID": "867788ffb7d7d5f42d86fc0b42e91d8f",
"id": null,
"metadata": {},
"name": "CreateOrganizationPageCreateOrganizationMutation",
"name": "CreateOrganizationViewCreateOrganizationMutation",
"operationKind": "mutation",
"text": "mutation CreateOrganizationPageCreateOrganizationMutation(\n $input: CreateOrganizationInput!\n) {\n createOrganization(input: $input) {\n organizationEdge {\n node {\n id\n name\n logoUrl\n }\n }\n }\n}\n"
"text": "mutation CreateOrganizationViewCreateOrganizationMutation(\n $input: CreateOrganizationInput!\n) {\n createOrganization(input: $input) {\n organizationEdge {\n node {\n id\n name\n logoUrl\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "ec0da70244afe2faa02b05ee418b829e";
(node as any).hash = "999f210db4fc65cbad47472294d9cf46";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<371bd1a7433b281f20dd8925915a7ff2>>
* @generated SignedSource<<4f30528c9f1b2def73d7058623843697>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,15 +9,15 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateOrganizationPageViewerQuery$variables = Record<PropertyKey, never>;
export type CreateOrganizationPageViewerQuery$data = {
export type CreateOrganizationViewViewerQuery$variables = Record<PropertyKey, never>;
export type CreateOrganizationViewViewerQuery$data = {
readonly viewer: {
readonly id: string;
};
};
export type CreateOrganizationPageViewerQuery = {
response: CreateOrganizationPageViewerQuery$data;
variables: CreateOrganizationPageViewerQuery$variables;
export type CreateOrganizationViewViewerQuery = {
response: CreateOrganizationViewViewerQuery$data;
variables: CreateOrganizationViewViewerQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -46,7 +46,7 @@ return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "CreateOrganizationPageViewerQuery",
"name": "CreateOrganizationViewViewerQuery",
"selections": (v0/*: any*/),
"type": "Query",
"abstractKey": null
@@ -55,20 +55,20 @@ return {
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "CreateOrganizationPageViewerQuery",
"name": "CreateOrganizationViewViewerQuery",
"selections": (v0/*: any*/)
},
"params": {
"cacheID": "8bb495e88bada2ffda332ffbc11f3340",
"cacheID": "70c2f23f09fbeac5b8e8436e6c68b5f6",
"id": null,
"metadata": {},
"name": "CreateOrganizationPageViewerQuery",
"name": "CreateOrganizationViewViewerQuery",
"operationKind": "query",
"text": "query CreateOrganizationPageViewerQuery {\n viewer {\n id\n }\n}\n"
"text": "query CreateOrganizationViewViewerQuery {\n viewer {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "2341c7947749857243e9f492235dfd61";
(node as any).hash = "200816c6c6173d8c7a4e5bcdab45dc45";
export default node;

View File

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

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<7fcee268b2974653bc3734b49b4082a4>>
* @generated SignedSource<<d22ffd34989ddeb523e19aab24261e58>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,10 +9,10 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type SettingsPageQuery$variables = {
export type SettingsViewQuery$variables = {
organizationID: string;
};
export type SettingsPageQuery$data = {
export type SettingsViewQuery$data = {
readonly organization: {
readonly id: string;
readonly logoUrl?: string | null | undefined;
@@ -29,9 +29,9 @@ export type SettingsPageQuery$data = {
};
};
};
export type SettingsPageQuery = {
response: SettingsPageQuery$data;
variables: SettingsPageQuery$variables;
export type SettingsViewQuery = {
response: SettingsViewQuery$data;
variables: SettingsViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -143,7 +143,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPageQuery",
"name": "SettingsViewQuery",
"selections": [
{
"alias": "organization",
@@ -166,7 +166,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsPageQuery",
"name": "SettingsViewQuery",
"selections": [
{
"alias": "organization",
@@ -191,16 +191,16 @@ return {
]
},
"params": {
"cacheID": "de417aa8583ad2d1cc7ef18223a4dc2d",
"cacheID": "1749c3e17b6efd13be678a0e968b7ac4",
"id": null,
"metadata": {},
"name": "SettingsPageQuery",
"name": "SettingsViewQuery",
"operationKind": "query",
"text": "query SettingsPageQuery(\n $organizationID: ID!\n) {\n organization: node(id: $organizationID) {\n __typename\n id\n ... on Organization {\n name\n logoUrl\n users(first: 100) {\n edges {\n node {\n id\n fullName\n email\n createdAt\n }\n }\n }\n }\n }\n}\n"
"text": "query SettingsViewQuery(\n $organizationID: ID!\n) {\n organization: node(id: $organizationID) {\n __typename\n id\n ... on Organization {\n name\n logoUrl\n users(first: 100) {\n edges {\n node {\n id\n fullName\n email\n createdAt\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "1d9482689dd9f305ca7a45fdf4ab088c";
(node as any).hash = "147757d21500b3eb42848ebe8a43bd9f";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<9d909d5fc7076593317b01aab165513d>>
* @generated SignedSource<<83fe53df5d49d9e2441a2e02dfaa63ee>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -13,17 +13,17 @@ export type RemoveUserInput = {
organizationId: string;
userId: string;
};
export type SettingsPageRemoveUserMutation$variables = {
export type SettingsViewRemoveUserMutation$variables = {
input: RemoveUserInput;
};
export type SettingsPageRemoveUserMutation$data = {
export type SettingsViewRemoveUserMutation$data = {
readonly removeUser: {
readonly success: boolean;
};
};
export type SettingsPageRemoveUserMutation = {
response: SettingsPageRemoveUserMutation$data;
variables: SettingsPageRemoveUserMutation$variables;
export type SettingsViewRemoveUserMutation = {
response: SettingsViewRemoveUserMutation$data;
variables: SettingsViewRemoveUserMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -65,7 +65,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPageRemoveUserMutation",
"name": "SettingsViewRemoveUserMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -74,20 +74,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsPageRemoveUserMutation",
"name": "SettingsViewRemoveUserMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "62588cd355b82b5a48d0b539a009b3bd",
"cacheID": "4a8d30e01815dbee5f216bbacf9f39e6",
"id": null,
"metadata": {},
"name": "SettingsPageRemoveUserMutation",
"name": "SettingsViewRemoveUserMutation",
"operationKind": "mutation",
"text": "mutation SettingsPageRemoveUserMutation(\n $input: RemoveUserInput!\n) {\n removeUser(input: $input) {\n success\n }\n}\n"
"text": "mutation SettingsViewRemoveUserMutation(\n $input: RemoveUserInput!\n) {\n removeUser(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "8d375bb2dfdab0f9d9520eee9f732b10";
(node as any).hash = "a6446703d214b8c6c436aabed2d78dd7";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<76018198697d82d6f630558618c6aaff>>
* @generated SignedSource<<f4b385701b9d41fb1d7617694a08c117>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,10 +14,10 @@ export type UpdateOrganizationInput = {
name?: string | null | undefined;
organizationId: string;
};
export type SettingsPageUpdateOrganizationMutation$variables = {
export type SettingsViewUpdateOrganizationMutation$variables = {
input: UpdateOrganizationInput;
};
export type SettingsPageUpdateOrganizationMutation$data = {
export type SettingsViewUpdateOrganizationMutation$data = {
readonly updateOrganization: {
readonly organization: {
readonly id: string;
@@ -26,9 +26,9 @@ export type SettingsPageUpdateOrganizationMutation$data = {
};
};
};
export type SettingsPageUpdateOrganizationMutation = {
response: SettingsPageUpdateOrganizationMutation$data;
variables: SettingsPageUpdateOrganizationMutation$variables;
export type SettingsViewUpdateOrganizationMutation = {
response: SettingsViewUpdateOrganizationMutation$data;
variables: SettingsViewUpdateOrganizationMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -95,7 +95,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPageUpdateOrganizationMutation",
"name": "SettingsViewUpdateOrganizationMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -104,20 +104,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsPageUpdateOrganizationMutation",
"name": "SettingsViewUpdateOrganizationMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "2bf2a6e054aed9bd337372682342a30f",
"cacheID": "f2a2984a2d4e764019f4355c13dab46e",
"id": null,
"metadata": {},
"name": "SettingsPageUpdateOrganizationMutation",
"name": "SettingsViewUpdateOrganizationMutation",
"operationKind": "mutation",
"text": "mutation SettingsPageUpdateOrganizationMutation(\n $input: UpdateOrganizationInput!\n) {\n updateOrganization(input: $input) {\n organization {\n id\n name\n logoUrl\n }\n }\n}\n"
"text": "mutation SettingsViewUpdateOrganizationMutation(\n $input: UpdateOrganizationInput!\n) {\n updateOrganization(input: $input) {\n organization {\n id\n name\n logoUrl\n }\n }\n}\n"
}
};
})();
(node as any).hash = "737ec44a08ac05e9f78827811cc0ca0e";
(node as any).hash = "9570926c4f654570cbfe7328cd9893d6";
export default node;

View File

@@ -1,215 +1,11 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreateFrameworkPageCreateFrameworkMutation } from "./__generated__/CreateFrameworkPageCreateFrameworkMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const createFrameworkMutation = graphql`
mutation CreateFrameworkPageCreateFrameworkMutation(
$input: CreateFrameworkInput!
$connections: [ID!]!
) {
createFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
name
description
}
}
}
}
`;
const CreateFrameworkView = lazy(() => import("./CreateFrameworkView"));
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-muted-foreground" />
<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>
);
}
function CreateFrameworkPageContent() {
const { organizationId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [formData, setFormData] = useState({
name: "",
description: "",
});
const [commit, isInFlight] =
useMutation<CreateFrameworkPageCreateFrameworkMutation>(
createFrameworkMutation
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListPage_frameworks"
);
commit({
variables: {
input: {
organizationId: organizationId!,
name: formData.name,
description: formData.description,
},
connections: [connectionId],
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to create framework",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Framework created successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to create framework",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Framework"
description="Create a new framework to organize your controls"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/frameworks`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Framework"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export function CreateFrameworkPageSkeleton() {
export function CreateFrameworkViewSkeleton() {
return (
<PageTemplateSkeleton
title="Create Framework"
@@ -220,10 +16,17 @@ export function CreateFrameworkPageSkeleton() {
);
}
export default function CreateFrameworkPage() {
export function CreateFrameworkPage() {
const location = useLocation();
return (
<Suspense fallback={<CreateFrameworkPageSkeleton />}>
<CreateFrameworkPageContent />
<Suspense
key={location.pathname}
fallback={<CreateFrameworkViewSkeleton />}
>
<ErrorBoundaryWithLocation>
<CreateFrameworkView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,219 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreateFrameworkViewCreateFrameworkMutation } from "./__generated__/CreateFrameworkViewCreateFrameworkMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { CreateFrameworkViewSkeleton } from "./CreateFrameworkPage";
const createFrameworkMutation = graphql`
mutation CreateFrameworkViewCreateFrameworkMutation(
$input: CreateFrameworkInput!
$connections: [ID!]!
) {
createFramework(input: $input) {
frameworkEdge @prependEdge(connections: $connections) {
node {
id
name
description
}
}
}
}
`;
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-muted-foreground" />
<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>
);
}
function CreateFrameworkViewContent() {
const { organizationId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [formData, setFormData] = useState({
name: "",
description: "",
});
const [commit, isInFlight] =
useMutation<CreateFrameworkViewCreateFrameworkMutation>(
createFrameworkMutation
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListPage_frameworks"
);
commit({
variables: {
input: {
organizationId: organizationId!,
name: formData.name,
description: formData.description,
},
connections: [connectionId],
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to create framework",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Framework created successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to create framework",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Framework"
description="Create a new framework to organize your controls"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/frameworks`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Framework"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export default function CreateFrameworkView() {
return (
<Suspense fallback={<CreateFrameworkViewSkeleton />}>
<CreateFrameworkViewContent />
</Suspense>
);
}

View File

@@ -6,7 +6,7 @@ import { Card, CardContent } from "@/components/ui/card";
const FrameworkListView = lazy(() => import("./FrameworkListView"));
export function FrameworkListSkeleton() {
export function FrameworkListViewSkeleton() {
return (
<PageTemplateSkeleton
title="Frameworks"
@@ -40,7 +40,7 @@ export function FrameworkListPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<FrameworkListSkeleton />}>
<Suspense key={location.pathname} fallback={<FrameworkListViewSkeleton />}>
<ErrorBoundaryWithLocation>
<FrameworkListView />
</ErrorBoundaryWithLocation>

View File

@@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { FrameworkListViewImportFrameworkMutation as FrameworkListViewImportFrameworkMutationType } from "./__generated__/FrameworkListViewImportFrameworkMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { FrameworkListSkeleton } from "./FrameworkListPage";
import { FrameworkListViewSkeleton } from "./FrameworkListPage";
const FrameworkListViewQuery = graphql`
query FrameworkListViewQuery($organizationId: ID!) {
@@ -292,11 +292,11 @@ export default function FrameworkListView() {
}, [loadQuery, organizationId]);
if (!queryRef) {
return <FrameworkListSkeleton />;
return <FrameworkListViewSkeleton />;
}
return (
<Suspense fallback={<FrameworkListSkeleton />}>
<Suspense fallback={<FrameworkListViewSkeleton />}>
{queryRef && <FrameworkListViewContent queryRef={queryRef} />}
</Suspense>
);

View File

@@ -1,240 +1,11 @@
import { Suspense, useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { UpdateFrameworkPageUpdateFrameworkMutation } from "./__generated__/UpdateFrameworkPageUpdateFrameworkMutation.graphql";
import { UpdateFrameworkPageQuery as UpdateFrameworkPageQueryType } from "./__generated__/UpdateFrameworkPageQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const updateFrameworkMutation = graphql`
mutation UpdateFrameworkPageUpdateFrameworkMutation(
$input: UpdateFrameworkInput!
) {
updateFramework(input: $input) {
framework {
id
name
description
version
}
}
}
`;
const UpdateFrameworkView = lazy(() => import("./UpdateFrameworkView"));
const updateFrameworkQuery = graphql`
query UpdateFrameworkPageQuery($frameworkId: ID!) {
node(id: $frameworkId) {
... on Framework {
id
name
description
version
}
}
}
`;
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-muted-foreground" />
<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>
);
}
function UpdateFrameworkPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<UpdateFrameworkPageQueryType>;
}) {
const { organizationId, frameworkId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const data = usePreloadedQuery(updateFrameworkQuery, queryRef);
const [, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
name: "",
description: "",
});
useEffect(() => {
if (data.node) {
setFormData({
name: data.node.name || "",
description: data.node.description || "",
});
}
}, [data.node]);
const [commit, isInFlight] =
useMutation<UpdateFrameworkPageUpdateFrameworkMutation>(
updateFrameworkMutation
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setEditedFields((prev) => new Set(prev).add(field));
};
const handleCancel = () => {
navigate(`/organizations/${organizationId}/frameworks/${frameworkId}`);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
commit({
variables: {
input: {
id: frameworkId!,
expectedVersion: data.node.version!,
name: formData.name,
description: formData.description,
},
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to update framework",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Framework updated successfully",
});
navigate(`/organizations/${organizationId}/frameworks/${frameworkId}`);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to update framework",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Update Framework"
description="Update the framework details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Updating..." : "Update Framework"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export function UpdateFrameworkPageSkeleton() {
export function UpdateFrameworkViewSkeleton() {
return (
<PageTemplateSkeleton
title="Update Framework"
@@ -245,24 +16,17 @@ export function UpdateFrameworkPageSkeleton() {
);
}
export default function UpdateFrameworkPage() {
const { frameworkId } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<UpdateFrameworkPageQueryType>(updateFrameworkQuery);
useEffect(() => {
if (frameworkId) {
loadQuery({ frameworkId });
}
}, [frameworkId, loadQuery]);
if (!queryRef) {
return <UpdateFrameworkPageSkeleton />;
}
export function UpdateFrameworkPage() {
const location = useLocation();
return (
<Suspense fallback={<UpdateFrameworkPageSkeleton />}>
<UpdateFrameworkPageContent queryRef={queryRef} />
<Suspense
key={location.pathname}
fallback={<UpdateFrameworkViewSkeleton />}
>
<ErrorBoundaryWithLocation>
<UpdateFrameworkView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,258 @@
import { Suspense, useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { UpdateFrameworkViewUpdateFrameworkMutation } from "./__generated__/UpdateFrameworkViewUpdateFrameworkMutation.graphql";
import { UpdateFrameworkViewQuery as UpdateFrameworkViewQueryType } from "./__generated__/UpdateFrameworkViewQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { UpdateFrameworkViewSkeleton } from "./UpdateFrameworkPage";
const updateFrameworkMutation = graphql`
mutation UpdateFrameworkViewUpdateFrameworkMutation(
$input: UpdateFrameworkInput!
) {
updateFramework(input: $input) {
framework {
id
name
description
version
}
}
}
`;
const updateFrameworkQuery = graphql`
query UpdateFrameworkViewQuery($frameworkId: ID!) {
node(id: $frameworkId) {
... on Framework {
id
name
description
version
}
}
}
`;
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-muted-foreground" />
<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>
);
}
function UpdateFrameworkViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<UpdateFrameworkViewQueryType>;
}) {
const { organizationId, frameworkId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const data = usePreloadedQuery(updateFrameworkQuery, queryRef);
const [, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
name: "",
description: "",
});
useEffect(() => {
if (data.node) {
setFormData({
name: data.node.name || "",
description: data.node.description || "",
});
}
}, [data.node]);
const [commit, isInFlight] =
useMutation<UpdateFrameworkViewUpdateFrameworkMutation>(
updateFrameworkMutation
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setEditedFields((prev) => new Set(prev).add(field));
};
const handleCancel = () => {
navigate(`/organizations/${organizationId}/frameworks/${frameworkId}`);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
commit({
variables: {
input: {
id: frameworkId!,
expectedVersion: data.node.version!,
name: formData.name,
description: formData.description,
},
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to update framework",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Framework updated successfully",
});
navigate(`/organizations/${organizationId}/frameworks/${frameworkId}`);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to update framework",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Update Framework"
description="Update the framework details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Updating..." : "Update Framework"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export default function UpdateFrameworkView() {
const { frameworkId } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<UpdateFrameworkViewQueryType>(updateFrameworkQuery);
useEffect(() => {
if (frameworkId) {
loadQuery({ frameworkId });
}
}, [frameworkId, loadQuery]);
if (!queryRef) {
return <UpdateFrameworkViewSkeleton />;
}
return (
<Suspense fallback={<UpdateFrameworkViewSkeleton />}>
<UpdateFrameworkViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<9c88cb0bf2c3491784bdea7e46c918a3>>
* @generated SignedSource<<dca7347e7f5c3d8271623553ee52094d>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,11 +14,11 @@ export type CreateFrameworkInput = {
name: string;
organizationId: string;
};
export type CreateFrameworkPageCreateFrameworkMutation$variables = {
export type CreateFrameworkViewCreateFrameworkMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateFrameworkInput;
};
export type CreateFrameworkPageCreateFrameworkMutation$data = {
export type CreateFrameworkViewCreateFrameworkMutation$data = {
readonly createFramework: {
readonly frameworkEdge: {
readonly node: {
@@ -29,9 +29,9 @@ export type CreateFrameworkPageCreateFrameworkMutation$data = {
};
};
};
export type CreateFrameworkPageCreateFrameworkMutation = {
response: CreateFrameworkPageCreateFrameworkMutation$data;
variables: CreateFrameworkPageCreateFrameworkMutation$variables;
export type CreateFrameworkViewCreateFrameworkMutation = {
response: CreateFrameworkViewCreateFrameworkMutation$data;
variables: CreateFrameworkViewCreateFrameworkMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -103,7 +103,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "CreateFrameworkPageCreateFrameworkMutation",
"name": "CreateFrameworkViewCreateFrameworkMutation",
"selections": [
{
"alias": null,
@@ -128,7 +128,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreateFrameworkPageCreateFrameworkMutation",
"name": "CreateFrameworkViewCreateFrameworkMutation",
"selections": [
{
"alias": null,
@@ -161,16 +161,16 @@ return {
]
},
"params": {
"cacheID": "142ffd990da914bddaa2752dfff0faaf",
"cacheID": "7502bf40a1731a4b7fd1a019ac438b39",
"id": null,
"metadata": {},
"name": "CreateFrameworkPageCreateFrameworkMutation",
"name": "CreateFrameworkViewCreateFrameworkMutation",
"operationKind": "mutation",
"text": "mutation CreateFrameworkPageCreateFrameworkMutation(\n $input: CreateFrameworkInput!\n) {\n createFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n description\n }\n }\n }\n}\n"
"text": "mutation CreateFrameworkViewCreateFrameworkMutation(\n $input: CreateFrameworkInput!\n) {\n createFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n description\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "60b83b4b5302f15a8e6f6b711c905963";
(node as any).hash = "cb800b9880232714d34698beb7bf8d84";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c4c46870b65e75baab882f1e9689c119>>
* @generated SignedSource<<08d71ea16ac38e11d5b1ce3fb11bcaf3>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,10 +9,10 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdateFrameworkPageQuery$variables = {
export type UpdateFrameworkViewQuery$variables = {
frameworkId: string;
};
export type UpdateFrameworkPageQuery$data = {
export type UpdateFrameworkViewQuery$data = {
readonly node: {
readonly description?: string;
readonly id?: string;
@@ -20,9 +20,9 @@ export type UpdateFrameworkPageQuery$data = {
readonly version?: number;
};
};
export type UpdateFrameworkPageQuery = {
response: UpdateFrameworkPageQuery$data;
variables: UpdateFrameworkPageQuery$variables;
export type UpdateFrameworkViewQuery = {
response: UpdateFrameworkViewQuery$data;
variables: UpdateFrameworkViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -73,7 +73,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateFrameworkPageQuery",
"name": "UpdateFrameworkViewQuery",
"selections": [
{
"alias": null,
@@ -105,7 +105,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateFrameworkPageQuery",
"name": "UpdateFrameworkViewQuery",
"selections": [
{
"alias": null,
@@ -139,16 +139,16 @@ return {
]
},
"params": {
"cacheID": "cae85e293ba2bd3d1d0f13acd1e777f5",
"cacheID": "180cbef2756c525af017ca6996a7c2d6",
"id": null,
"metadata": {},
"name": "UpdateFrameworkPageQuery",
"name": "UpdateFrameworkViewQuery",
"operationKind": "query",
"text": "query UpdateFrameworkPageQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n ... on Framework {\n id\n name\n description\n version\n }\n id\n }\n}\n"
"text": "query UpdateFrameworkViewQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n ... on Framework {\n id\n name\n description\n version\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "56744b0fcc4892c88f70c71b0ca49b90";
(node as any).hash = "d644d01f1d8a31b6c87c7f6360996796";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<822ef9b3867da7de4bca37e5be0e077e>>
* @generated SignedSource<<115402b83869511df5e91ab206ae99e3>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -15,10 +15,10 @@ export type UpdateFrameworkInput = {
id: string;
name?: string | null | undefined;
};
export type UpdateFrameworkPageUpdateFrameworkMutation$variables = {
export type UpdateFrameworkViewUpdateFrameworkMutation$variables = {
input: UpdateFrameworkInput;
};
export type UpdateFrameworkPageUpdateFrameworkMutation$data = {
export type UpdateFrameworkViewUpdateFrameworkMutation$data = {
readonly updateFramework: {
readonly framework: {
readonly description: string;
@@ -28,9 +28,9 @@ export type UpdateFrameworkPageUpdateFrameworkMutation$data = {
};
};
};
export type UpdateFrameworkPageUpdateFrameworkMutation = {
response: UpdateFrameworkPageUpdateFrameworkMutation$data;
variables: UpdateFrameworkPageUpdateFrameworkMutation$variables;
export type UpdateFrameworkViewUpdateFrameworkMutation = {
response: UpdateFrameworkViewUpdateFrameworkMutation$data;
variables: UpdateFrameworkViewUpdateFrameworkMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -104,7 +104,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateFrameworkPageUpdateFrameworkMutation",
"name": "UpdateFrameworkViewUpdateFrameworkMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -113,20 +113,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateFrameworkPageUpdateFrameworkMutation",
"name": "UpdateFrameworkViewUpdateFrameworkMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "d85d38851d3d94709a5b6c4090678ea4",
"cacheID": "08fc83afdc1227aa23cac0778cdd2cc0",
"id": null,
"metadata": {},
"name": "UpdateFrameworkPageUpdateFrameworkMutation",
"name": "UpdateFrameworkViewUpdateFrameworkMutation",
"operationKind": "mutation",
"text": "mutation UpdateFrameworkPageUpdateFrameworkMutation(\n $input: UpdateFrameworkInput!\n) {\n updateFramework(input: $input) {\n framework {\n id\n name\n description\n version\n }\n }\n}\n"
"text": "mutation UpdateFrameworkViewUpdateFrameworkMutation(\n $input: UpdateFrameworkInput!\n) {\n updateFramework(input: $input) {\n framework {\n id\n name\n description\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "c363b35a095da9142d61907ca7ba3c62";
(node as any).hash = "6dfde24d73c2cbc0c5ca147d076fbd26";
export default node;

View File

@@ -0,0 +1,44 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Card, CardContent } from "@/components/ui/card";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../../ErrorBoundary";
const ControlView = lazy(() => import("./ControlView"));
export function ControlViewSkeleton() {
return (
<PageTemplateSkeleton
actions={
<div className="flex items-center gap-2 w-1/3">
<div className="bg-muted animate-pulse h-8 w-1/3 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/3 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/3 rounded-full" />
</div>
}
>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</PageTemplateSkeleton>
);
}
export function ControlPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<ControlViewSkeleton />}>
<ErrorBoundaryWithLocation>
<ControlView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -64,17 +64,18 @@ import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import type { ControlOverviewPageQuery as ControlOverviewPageQueryType } from "./__generated__/ControlOverviewPageQuery.graphql";
import type { ControlOverviewPageUpdateTaskStateMutation as ControlOverviewPageUpdateTaskStateMutationType } from "./__generated__/ControlOverviewPageUpdateTaskStateMutation.graphql";
import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql";
import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql";
import type { ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType } from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql";
import type { ControlOverviewPageDeleteEvidenceMutation as ControlOverviewPageDeleteEvidenceMutationType } from "./__generated__/ControlOverviewPageDeleteEvidenceMutation.graphql";
import type { ControlOverviewPageAssignTaskMutation as ControlOverviewPageAssignTaskMutationType } from "./__generated__/ControlOverviewPageAssignTaskMutation.graphql";
import type { ControlOverviewPageUnassignTaskMutation as ControlOverviewPageUnassignTaskMutationType } from "./__generated__/ControlOverviewPageUnassignTaskMutation.graphql";
import type { ControlOverviewPageOrganizationQuery$data } from "./__generated__/ControlOverviewPageOrganizationQuery.graphql";
import type { ControlOverviewPageUpdateControlStateMutation as ControlOverviewPageUpdateControlStateMutationType } from "./__generated__/ControlOverviewPageUpdateControlStateMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import type { ControlViewQuery as ControlViewQueryType } from "./__generated__/ControlViewQuery.graphql";
import type { ControlViewUpdateTaskStateMutation as ControlViewUpdateTaskStateMutationType } from "./__generated__/ControlViewUpdateTaskStateMutation.graphql";
import type { ControlViewCreateTaskMutation as ControlViewCreateTaskMutationType } from "./__generated__/ControlViewCreateTaskMutation.graphql";
import type { ControlViewDeleteTaskMutation as ControlViewDeleteTaskMutationType } from "./__generated__/ControlViewDeleteTaskMutation.graphql";
import type { ControlViewUploadEvidenceMutation as ControlViewUploadEvidenceMutationType } from "./__generated__/ControlViewUploadEvidenceMutation.graphql";
import type { ControlViewDeleteEvidenceMutation as ControlViewDeleteEvidenceMutationType } from "./__generated__/ControlViewDeleteEvidenceMutation.graphql";
import type { ControlViewAssignTaskMutation as ControlViewAssignTaskMutationType } from "./__generated__/ControlViewAssignTaskMutation.graphql";
import type { ControlViewUnassignTaskMutation as ControlViewUnassignTaskMutationType } from "./__generated__/ControlViewUnassignTaskMutation.graphql";
import type { ControlViewOrganizationQuery$data } from "./__generated__/ControlViewOrganizationQuery.graphql";
import type { ControlViewUpdateControlStateMutation as ControlViewUpdateControlStateMutationType } from "./__generated__/ControlViewUpdateControlStateMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { ControlViewSkeleton } from "./ControlPage";
// Function to format ISO8601 duration to human-readable format
const formatDuration = (isoDuration: string): string => {
@@ -113,8 +114,8 @@ const formatDuration = (isoDuration: string): string => {
}
};
const controlOverviewPageQuery = graphql`
query ControlOverviewPageQuery($controlId: ID!) {
const controlViewQuery = graphql`
query ControlViewQuery($controlId: ID!) {
control: node(id: $controlId) {
id
... on Control {
@@ -124,7 +125,7 @@ const controlOverviewPageQuery = graphql`
importance
category
version
tasks(first: 100) @connection(key: "ControlOverviewPage_tasks") {
tasks(first: 100) @connection(key: "ControlView_tasks") {
__id
edges {
node {
@@ -139,8 +140,7 @@ const controlOverviewPageQuery = graphql`
fullName
primaryEmailAddress
}
evidences(first: 50)
@connection(key: "ControlOverviewPage_evidences") {
evidences(first: 50) @connection(key: "ControlView_evidences") {
__id
edges {
node {
@@ -164,9 +164,7 @@ const controlOverviewPageQuery = graphql`
`;
const updateTaskStateMutation = graphql`
mutation ControlOverviewPageUpdateTaskStateMutation(
$input: UpdateTaskInput!
) {
mutation ControlViewUpdateTaskStateMutation($input: UpdateTaskInput!) {
updateTask(input: $input) {
task {
id
@@ -178,7 +176,7 @@ const updateTaskStateMutation = graphql`
`;
const createTaskMutation = graphql`
mutation ControlOverviewPageCreateTaskMutation(
mutation ControlViewCreateTaskMutation(
$input: CreateTaskInput!
$connections: [ID!]!
) {
@@ -203,7 +201,7 @@ const createTaskMutation = graphql`
`;
const deleteTaskMutation = graphql`
mutation ControlOverviewPageDeleteTaskMutation(
mutation ControlViewDeleteTaskMutation(
$input: DeleteTaskInput!
$connections: [ID!]!
) {
@@ -214,7 +212,7 @@ const deleteTaskMutation = graphql`
`;
const uploadEvidenceMutation = graphql`
mutation ControlOverviewPageUploadEvidenceMutation(
mutation ControlViewUploadEvidenceMutation(
$input: UploadEvidenceInput!
$connections: [ID!]!
) {
@@ -237,7 +235,7 @@ const uploadEvidenceMutation = graphql`
`;
const deleteEvidenceMutation = graphql`
mutation ControlOverviewPageDeleteEvidenceMutation(
mutation ControlViewDeleteEvidenceMutation(
$input: DeleteEvidenceInput!
$connections: [ID!]!
) {
@@ -249,7 +247,7 @@ const deleteEvidenceMutation = graphql`
// Add a GraphQL query to fetch the fileUrl for an evidence item
const getEvidenceFileUrlQuery = graphql`
query ControlOverviewPageGetEvidenceFileUrlQuery($evidenceId: ID!) {
query ControlViewGetEvidenceFileUrlQuery($evidenceId: ID!) {
node(id: $evidenceId) {
... on Evidence {
id
@@ -260,7 +258,7 @@ const getEvidenceFileUrlQuery = graphql`
`;
const assignTaskMutation = graphql`
mutation ControlOverviewPageAssignTaskMutation($input: AssignTaskInput!) {
mutation ControlViewAssignTaskMutation($input: AssignTaskInput!) {
assignTask(input: $input) {
task {
id
@@ -276,7 +274,7 @@ const assignTaskMutation = graphql`
`;
const unassignTaskMutation = graphql`
mutation ControlOverviewPageUnassignTaskMutation($input: UnassignTaskInput!) {
mutation ControlViewUnassignTaskMutation($input: UnassignTaskInput!) {
unassignTask(input: $input) {
task {
id
@@ -292,9 +290,7 @@ const unassignTaskMutation = graphql`
`;
const updateControlStateMutation = graphql`
mutation ControlOverviewPageUpdateControlStateMutation(
$input: UpdateControlInput!
) {
mutation ControlViewUpdateControlStateMutation($input: UpdateControlInput!) {
updateControl(input: $input) {
control {
id
@@ -306,12 +302,12 @@ const updateControlStateMutation = graphql`
`;
const organizationQuery = graphql`
query ControlOverviewPageOrganizationQuery($organizationId: ID!) {
query ControlViewOrganizationQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
peoples(first: 100, orderBy: { direction: ASC, field: FULL_NAME })
@connection(key: "ControlOverviewPage_peoples") {
@connection(key: "ControlView_peoples") {
edges {
node {
id
@@ -325,13 +321,13 @@ const organizationQuery = graphql`
}
`;
function ControlOverviewPageContent({
function ControlViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<ControlOverviewPageQueryType>;
queryRef: PreloadedQuery<ControlViewQueryType>;
}) {
const data = usePreloadedQuery<ControlOverviewPageQueryType>(
controlOverviewPageQuery,
const data = usePreloadedQuery<ControlViewQueryType>(
controlViewQuery,
queryRef
);
const { toast } = useToast();
@@ -341,7 +337,7 @@ function ControlOverviewPageContent({
// Load organization data for people selector
const [organizationData, setOrganizationData] =
useState<ControlOverviewPageOrganizationQuery$data | null>(null);
useState<ControlViewOrganizationQuery$data | null>(null);
useEffect(() => {
if (organizationId) {
@@ -350,9 +346,7 @@ function ControlOverviewPageContent({
})
.toPromise()
.then((response) => {
setOrganizationData(
response as ControlOverviewPageOrganizationQuery$data
);
setOrganizationData(response as ControlViewOrganizationQuery$data);
})
.catch((error) => {
console.error("Error fetching organization data:", error);
@@ -417,31 +411,26 @@ function ControlOverviewPageContent({
return "bg-gray-100 text-gray-800";
};
const [updateTask] =
useMutation<ControlOverviewPageUpdateTaskStateMutationType>(
updateTaskStateMutation
);
const [updateTask] = useMutation<ControlViewUpdateTaskStateMutationType>(
updateTaskStateMutation
);
const [createTask] =
useMutation<ControlOverviewPageCreateTaskMutationType>(createTaskMutation);
useMutation<ControlViewCreateTaskMutationType>(createTaskMutation);
const [deleteTask] =
useMutation<ControlOverviewPageDeleteTaskMutationType>(deleteTaskMutation);
const [uploadEvidence] =
useMutation<ControlOverviewPageUploadEvidenceMutationType>(
uploadEvidenceMutation
);
const [deleteEvidence] =
useMutation<ControlOverviewPageDeleteEvidenceMutationType>(
deleteEvidenceMutation
);
useMutation<ControlViewDeleteTaskMutationType>(deleteTaskMutation);
const [uploadEvidence] = useMutation<ControlViewUploadEvidenceMutationType>(
uploadEvidenceMutation
);
const [deleteEvidence] = useMutation<ControlViewDeleteEvidenceMutationType>(
deleteEvidenceMutation
);
const [assignTask] =
useMutation<ControlOverviewPageAssignTaskMutationType>(assignTaskMutation);
useMutation<ControlViewAssignTaskMutationType>(assignTaskMutation);
const [unassignTask] =
useMutation<ControlOverviewPageUnassignTaskMutationType>(
unassignTaskMutation
);
useMutation<ControlViewUnassignTaskMutationType>(unassignTaskMutation);
const [updateControlState] =
useMutation<ControlOverviewPageUpdateControlStateMutationType>(
useMutation<ControlViewUpdateControlStateMutationType>(
updateControlStateMutation
);
@@ -1971,36 +1960,10 @@ function ControlOverviewPageContent({
);
}
export function ControlOverviewPageSkeleton() {
return (
<PageTemplateSkeleton
actions={
<div className="flex items-center gap-2 w-1/3">
<div className="bg-muted animate-pulse h-8 w-1/3 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/3 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/3 rounded-full" />
</div>
}
>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</PageTemplateSkeleton>
);
}
export default function ControlOverviewPage() {
export default function ControlView() {
const { controlId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<ControlOverviewPageQueryType>(
controlOverviewPageQuery
);
const [queryRef, loadQuery] =
useQueryLoader<ControlViewQueryType>(controlViewQuery);
useEffect(() => {
if (controlId) {
@@ -2009,12 +1972,12 @@ export default function ControlOverviewPage() {
}, [controlId, loadQuery]);
if (!queryRef) {
return <ControlOverviewPageSkeleton />;
return <ControlViewSkeleton />;
}
return (
<Suspense fallback={<ControlOverviewPageSkeleton />}>
<ControlOverviewPageContent queryRef={queryRef} />
<Suspense fallback={<ControlViewSkeleton />}>
<ControlViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,260 +1,11 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CreateControlPageCreateControlMutation,
ControlImportance,
} from "./__generated__/CreateControlPageCreateControlMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { ErrorBoundaryWithLocation } from "../../ErrorBoundary";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
const createControlMutation = graphql`
mutation CreateControlPageCreateControlMutation(
$input: CreateControlInput!
$connections: [ID!]!
) {
createControl(input: $input) {
controlEdge @prependEdge(connections: $connections) {
node {
id
name
description
category
state
}
}
}
}
`;
const CreateControlView = lazy(() => import("./CreateControlView"));
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-muted-foreground" />
<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>
);
}
function CreateControlPageContent() {
const { organizationId, frameworkId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
importance: "MANDATORY" as ControlImportance,
});
const [commit, isInFlight] =
useMutation<CreateControlPageCreateControlMutation>(createControlMutation);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description || !formData.category) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const connectionId = ConnectionHandler.getConnectionID(
frameworkId!,
"FrameworkOverviewPage_controls"
);
commit({
variables: {
input: {
frameworkId: frameworkId!,
name: formData.name,
description: formData.description,
category: formData.category,
importance: formData.importance,
},
connections: [connectionId],
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to create control",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Control created successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${data.createControl.controlEdge.node.id}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to create control",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Control"
description="Create a new control for your framework"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
helpText="The category this control belongs to"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) =>
handleFieldChange("importance", value as ControlImportance)
}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Control"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export function CreateControlPageSkeleton() {
export function CreateControlViewSkeleton() {
return (
<PageTemplateSkeleton
title="Create Control"
@@ -265,10 +16,14 @@ export function CreateControlPageSkeleton() {
);
}
export default function CreateControlPage() {
export function CreateControlPage() {
const location = useLocation();
return (
<Suspense fallback={<CreateControlPageSkeleton />}>
<CreateControlPageContent />
<Suspense key={location.pathname} fallback={<CreateControlViewSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateControlView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,264 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CreateControlViewCreateControlMutation,
ControlImportance,
} from "./__generated__/CreateControlViewCreateControlMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { CreateControlViewSkeleton } from "./CreateControlPage";
const createControlMutation = graphql`
mutation CreateControlViewCreateControlMutation(
$input: CreateControlInput!
$connections: [ID!]!
) {
createControl(input: $input) {
controlEdge @prependEdge(connections: $connections) {
node {
id
name
description
category
state
}
}
}
}
`;
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-muted-foreground" />
<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>
);
}
function CreateControlViewContent() {
const { organizationId, frameworkId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
importance: "MANDATORY" as ControlImportance,
});
const [commit, isInFlight] =
useMutation<CreateControlViewCreateControlMutation>(createControlMutation);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description || !formData.category) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const connectionId = ConnectionHandler.getConnectionID(
frameworkId!,
"FrameworkOverviewPage_controls"
);
commit({
variables: {
input: {
frameworkId: frameworkId!,
name: formData.name,
description: formData.description,
category: formData.category,
importance: formData.importance,
},
connections: [connectionId],
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to create control",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Control created successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${data.createControl.controlEdge.node.id}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to create control",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Control"
description="Create a new control for your framework"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
helpText="The category this control belongs to"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) =>
handleFieldChange("importance", value as ControlImportance)
}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Control"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export default function CreateControlView() {
return (
<Suspense fallback={<CreateControlViewSkeleton />}>
<CreateControlViewContent />
</Suspense>
);
}

View File

@@ -1,337 +1,11 @@
import { Suspense, useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { UpdateControlPageUpdateControlMutation as UpdateControlPageUpdateControlMutationType } from "./__generated__/UpdateControlPageUpdateControlMutation.graphql";
import type {
ControlState,
ControlImportance,
} from "./__generated__/UpdateControlPageUpdateControlMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../../ErrorBoundary";
const updateControlMutation = graphql`
mutation UpdateControlPageUpdateControlMutation($input: UpdateControlInput!) {
updateControl(input: $input) {
control {
id
name
description
category
importance
state
version
}
}
}
`;
const UpdateControlView = lazy(() => import("./UpdateControlView"));
const updateControlQuery = graphql`
query UpdateControlPageQuery($controlId: ID!) {
node(id: $controlId) {
... on Control {
id
name
description
category
importance
state
version
}
}
}
`;
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-muted-foreground" />
<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>
);
}
function UpdateControlPageContent({
queryRef,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryRef: PreloadedQuery<any>;
}) {
const { organizationId, frameworkId, controlId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const data = usePreloadedQuery(updateControlQuery, queryRef);
const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
state: "",
importance: "",
});
useEffect(() => {
if (data.node) {
setFormData({
name: data.node.name || "",
description: data.node.description || "",
category: data.node.category || "",
state: data.node.state || "",
importance: data.node.importance || "",
});
}
}, [data.node]);
const [commit, isInFlight] =
useMutation<UpdateControlPageUpdateControlMutationType>(
updateControlMutation
);
const handleFieldChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setEditedFields((prev) => new Set(prev).add(field));
};
const handleCancel = () => {
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}`
);
};
const hasChanges = editedFields.size > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description || !formData.category) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const input: {
id: string;
expectedVersion: number;
name?: string;
description?: string;
category?: string;
state?: ControlState;
importance?: ControlImportance;
} = {
id: controlId!,
expectedVersion: data.node.version,
};
if (editedFields.has("name")) {
input.name = formData.name;
}
if (editedFields.has("description")) {
input.description = formData.description;
}
if (editedFields.has("category")) {
input.category = formData.category;
}
if (editedFields.has("state")) {
input.state = formData.state as ControlState;
}
if (editedFields.has("importance")) {
input.importance = formData.importance as ControlImportance;
}
commit({
variables: {
input,
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to update control",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Control updated successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to update control",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Update Control"
description="Update the control details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) => handleFieldChange("importance", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium">
State
</Label>
<Select
value={formData.state}
onValueChange={(value) => handleFieldChange("state", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="NOT_APPLICABLE">Not Applicable</SelectItem>
<SelectItem value="IMPLEMENTED">Implemented</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight || !hasChanges}>
{isInFlight ? "Updating..." : "Update Control"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export function UpdateControlPageSkeleton() {
export function UpdateControlViewSkeleton() {
return (
<PageTemplateSkeleton
title="Update Control"
@@ -342,24 +16,14 @@ export function UpdateControlPageSkeleton() {
);
}
export default function UpdateControlPage() {
const { controlId } = useParams();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [queryRef, loadQuery] = useQueryLoader<any>(updateControlQuery);
useEffect(() => {
if (controlId) {
loadQuery({ controlId });
}
}, [controlId, loadQuery]);
if (!queryRef) {
return <UpdateControlPageSkeleton />;
}
export function UpdateControlPage() {
const location = useLocation();
return (
<Suspense fallback={<UpdateControlPageSkeleton />}>
<UpdateControlPageContent queryRef={queryRef} />
<Suspense key={location.pathname} fallback={<UpdateControlViewSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdateControlView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,355 @@
import { Suspense, useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { UpdateControlViewUpdateControlMutation as UpdateControlViewUpdateControlMutationType } from "./__generated__/UpdateControlViewUpdateControlMutation.graphql";
import type {
ControlState,
ControlImportance,
} from "./__generated__/UpdateControlViewUpdateControlMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { UpdateControlViewSkeleton } from "./UpdateControlPage";
const updateControlMutation = graphql`
mutation UpdateControlViewUpdateControlMutation($input: UpdateControlInput!) {
updateControl(input: $input) {
control {
id
name
description
category
importance
state
version
}
}
}
`;
const updateControlQuery = graphql`
query UpdateControlViewQuery($controlId: ID!) {
node(id: $controlId) {
... on Control {
id
name
description
category
importance
state
version
}
}
}
`;
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-muted-foreground" />
<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>
);
}
function UpdateControlViewContent({
queryRef,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryRef: PreloadedQuery<any>;
}) {
const { organizationId, frameworkId, controlId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const data = usePreloadedQuery(updateControlQuery, queryRef);
const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
state: "",
importance: "",
});
useEffect(() => {
if (data.node) {
setFormData({
name: data.node.name || "",
description: data.node.description || "",
category: data.node.category || "",
state: data.node.state || "",
importance: data.node.importance || "",
});
}
}, [data.node]);
const [commit, isInFlight] =
useMutation<UpdateControlViewUpdateControlMutationType>(
updateControlMutation
);
const handleFieldChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setEditedFields((prev) => new Set(prev).add(field));
};
const handleCancel = () => {
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}`
);
};
const hasChanges = editedFields.size > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description || !formData.category) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const input: {
id: string;
expectedVersion: number;
name?: string;
description?: string;
category?: string;
state?: ControlState;
importance?: ControlImportance;
} = {
id: controlId!,
expectedVersion: data.node.version,
};
if (editedFields.has("name")) {
input.name = formData.name;
}
if (editedFields.has("description")) {
input.description = formData.description;
}
if (editedFields.has("category")) {
input.category = formData.category;
}
if (editedFields.has("state")) {
input.state = formData.state as ControlState;
}
if (editedFields.has("importance")) {
input.importance = formData.importance as ControlImportance;
}
commit({
variables: {
input,
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to update control",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Control updated successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to update control",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Update Control"
description="Update the control details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) => handleFieldChange("importance", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium">
State
</Label>
<Select
value={formData.state}
onValueChange={(value) => handleFieldChange("state", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="NOT_APPLICABLE">Not Applicable</SelectItem>
<SelectItem value="IMPLEMENTED">Implemented</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight || !hasChanges}>
{isInFlight ? "Updating..." : "Update Control"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export default function UpdateControlView() {
const { controlId } = useParams();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [queryRef, loadQuery] = useQueryLoader<any>(updateControlQuery);
useEffect(() => {
if (controlId) {
loadQuery({ controlId });
}
}, [controlId, loadQuery]);
if (!queryRef) {
return <UpdateControlViewSkeleton />;
}
return (
<Suspense fallback={<UpdateControlViewSkeleton />}>
<UpdateControlViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<bd1f2132960c1cdd84906d7de58765e9>>
* @generated SignedSource<<18bc7a503d34d2bcb4cdfb6b4cfc1912>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -13,10 +13,10 @@ export type AssignTaskInput = {
assignedToId: string;
taskId: string;
};
export type ControlOverviewPageAssignTaskMutation$variables = {
export type ControlViewAssignTaskMutation$variables = {
input: AssignTaskInput;
};
export type ControlOverviewPageAssignTaskMutation$data = {
export type ControlViewAssignTaskMutation$data = {
readonly assignTask: {
readonly task: {
readonly assignedTo: {
@@ -29,9 +29,9 @@ export type ControlOverviewPageAssignTaskMutation$data = {
};
};
};
export type ControlOverviewPageAssignTaskMutation = {
response: ControlOverviewPageAssignTaskMutation$data;
variables: ControlOverviewPageAssignTaskMutation$variables;
export type ControlViewAssignTaskMutation = {
response: ControlViewAssignTaskMutation$data;
variables: ControlViewAssignTaskMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -118,7 +118,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageAssignTaskMutation",
"name": "ControlViewAssignTaskMutation",
"selections": (v2/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -127,20 +127,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageAssignTaskMutation",
"name": "ControlViewAssignTaskMutation",
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "2a75b40af9d9853b851d153595dc1317",
"cacheID": "0a406c0e166607d86896adc0dfb5618e",
"id": null,
"metadata": {},
"name": "ControlOverviewPageAssignTaskMutation",
"name": "ControlViewAssignTaskMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageAssignTaskMutation(\n $input: AssignTaskInput!\n) {\n assignTask(input: $input) {\n task {\n id\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n}\n"
"text": "mutation ControlViewAssignTaskMutation(\n $input: AssignTaskInput!\n) {\n assignTask(input: $input) {\n task {\n id\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "02f3b1fb21bc39cea7fde9cc3945adc6";
(node as any).hash = "1a9a18038c2f6ad39d3efe61d2d0e33d";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<318d80720efe2f6e610353e3a640d786>>
* @generated SignedSource<<93ac47858fbd7c339faac83f193cb21b>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -17,11 +17,11 @@ export type CreateTaskInput = {
name: string;
timeEstimate?: any | null | undefined;
};
export type ControlOverviewPageCreateTaskMutation$variables = {
export type ControlViewCreateTaskMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateTaskInput;
};
export type ControlOverviewPageCreateTaskMutation$data = {
export type ControlViewCreateTaskMutation$data = {
readonly createTask: {
readonly taskEdge: {
readonly node: {
@@ -40,9 +40,9 @@ export type ControlOverviewPageCreateTaskMutation$data = {
};
};
};
export type ControlOverviewPageCreateTaskMutation = {
response: ControlOverviewPageCreateTaskMutation$data;
variables: ControlOverviewPageCreateTaskMutation$variables;
export type ControlViewCreateTaskMutation = {
response: ControlViewCreateTaskMutation$data;
variables: ControlViewCreateTaskMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -162,7 +162,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageCreateTaskMutation",
"name": "ControlViewCreateTaskMutation",
"selections": [
{
"alias": null,
@@ -187,7 +187,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "ControlOverviewPageCreateTaskMutation",
"name": "ControlViewCreateTaskMutation",
"selections": [
{
"alias": null,
@@ -220,16 +220,16 @@ return {
]
},
"params": {
"cacheID": "ea82092f026318c66f62c910449be445",
"cacheID": "f823a58c96ee652fd522df58de3710a1",
"id": null,
"metadata": {},
"name": "ControlOverviewPageCreateTaskMutation",
"name": "ControlViewCreateTaskMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageCreateTaskMutation(\n $input: CreateTaskInput!\n) {\n createTask(input: $input) {\n taskEdge {\n node {\n id\n name\n description\n timeEstimate\n state\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n }\n}\n"
"text": "mutation ControlViewCreateTaskMutation(\n $input: CreateTaskInput!\n) {\n createTask(input: $input) {\n taskEdge {\n node {\n id\n name\n description\n timeEstimate\n state\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "1587bb98852e8a5ff4e4d6cb408f4d1e";
(node as any).hash = "5d50a685dad17446a0cab1891dc00374";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c09b83c20157fc20bf74a2c9c031b716>>
* @generated SignedSource<<5cc5a8fcb02623e685586d2f3510fb4a>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
export type DeleteEvidenceInput = {
evidenceId: string;
};
export type ControlOverviewPageDeleteEvidenceMutation$variables = {
export type ControlViewDeleteEvidenceMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteEvidenceInput;
};
export type ControlOverviewPageDeleteEvidenceMutation$data = {
export type ControlViewDeleteEvidenceMutation$data = {
readonly deleteEvidence: {
readonly deletedEvidenceId: string;
};
};
export type ControlOverviewPageDeleteEvidenceMutation = {
response: ControlOverviewPageDeleteEvidenceMutation$data;
variables: ControlOverviewPageDeleteEvidenceMutation$variables;
export type ControlViewDeleteEvidenceMutation = {
response: ControlViewDeleteEvidenceMutation$data;
variables: ControlViewDeleteEvidenceMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -59,7 +59,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageDeleteEvidenceMutation",
"name": "ControlViewDeleteEvidenceMutation",
"selections": [
{
"alias": null,
@@ -84,7 +84,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "ControlOverviewPageDeleteEvidenceMutation",
"name": "ControlViewDeleteEvidenceMutation",
"selections": [
{
"alias": null,
@@ -117,16 +117,16 @@ return {
]
},
"params": {
"cacheID": "51d62fb682189fa0aaa5cc5b7a5b7862",
"cacheID": "594da4829274c817424f9b84ba99a5a6",
"id": null,
"metadata": {},
"name": "ControlOverviewPageDeleteEvidenceMutation",
"name": "ControlViewDeleteEvidenceMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageDeleteEvidenceMutation(\n $input: DeleteEvidenceInput!\n) {\n deleteEvidence(input: $input) {\n deletedEvidenceId\n }\n}\n"
"text": "mutation ControlViewDeleteEvidenceMutation(\n $input: DeleteEvidenceInput!\n) {\n deleteEvidence(input: $input) {\n deletedEvidenceId\n }\n}\n"
}
};
})();
(node as any).hash = "f55cf1c279c95dcabc11e1b5aafcdc46";
(node as any).hash = "a6b5e90e931adf04f45be9403b291ad9";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<cd5744c9739535825e9922b9cb95f034>>
* @generated SignedSource<<e6c50692038abaca7921b9f0810ddfaa>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
export type DeleteTaskInput = {
taskId: string;
};
export type ControlOverviewPageDeleteTaskMutation$variables = {
export type ControlViewDeleteTaskMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteTaskInput;
};
export type ControlOverviewPageDeleteTaskMutation$data = {
export type ControlViewDeleteTaskMutation$data = {
readonly deleteTask: {
readonly deletedTaskId: string;
};
};
export type ControlOverviewPageDeleteTaskMutation = {
response: ControlOverviewPageDeleteTaskMutation$data;
variables: ControlOverviewPageDeleteTaskMutation$variables;
export type ControlViewDeleteTaskMutation = {
response: ControlViewDeleteTaskMutation$data;
variables: ControlViewDeleteTaskMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -59,7 +59,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageDeleteTaskMutation",
"name": "ControlViewDeleteTaskMutation",
"selections": [
{
"alias": null,
@@ -84,7 +84,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "ControlOverviewPageDeleteTaskMutation",
"name": "ControlViewDeleteTaskMutation",
"selections": [
{
"alias": null,
@@ -117,16 +117,16 @@ return {
]
},
"params": {
"cacheID": "06db9599a3e83354c0e865d08cdb29dd",
"cacheID": "36b910121f02343b2e920adda58d7032",
"id": null,
"metadata": {},
"name": "ControlOverviewPageDeleteTaskMutation",
"name": "ControlViewDeleteTaskMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageDeleteTaskMutation(\n $input: DeleteTaskInput!\n) {\n deleteTask(input: $input) {\n deletedTaskId\n }\n}\n"
"text": "mutation ControlViewDeleteTaskMutation(\n $input: DeleteTaskInput!\n) {\n deleteTask(input: $input) {\n deletedTaskId\n }\n}\n"
}
};
})();
(node as any).hash = "885a98007e48ab3175d0480c5a750e19";
(node as any).hash = "3aa9ea0eca32df8e6fe0258ee1140e56";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<5fff8009247229346c3cda94359f2129>>
* @generated SignedSource<<6097cb76bff61c342c2b6389a8e7f618>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,18 +9,18 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlOverviewPageGetEvidenceFileUrlQuery$variables = {
export type ControlViewGetEvidenceFileUrlQuery$variables = {
evidenceId: string;
};
export type ControlOverviewPageGetEvidenceFileUrlQuery$data = {
export type ControlViewGetEvidenceFileUrlQuery$data = {
readonly node: {
readonly fileUrl?: string | null | undefined;
readonly id?: string;
};
};
export type ControlOverviewPageGetEvidenceFileUrlQuery = {
response: ControlOverviewPageGetEvidenceFileUrlQuery$data;
variables: ControlOverviewPageGetEvidenceFileUrlQuery$variables;
export type ControlViewGetEvidenceFileUrlQuery = {
response: ControlViewGetEvidenceFileUrlQuery$data;
variables: ControlViewGetEvidenceFileUrlQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -57,7 +57,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageGetEvidenceFileUrlQuery",
"name": "ControlViewGetEvidenceFileUrlQuery",
"selections": [
{
"alias": null,
@@ -87,7 +87,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageGetEvidenceFileUrlQuery",
"name": "ControlViewGetEvidenceFileUrlQuery",
"selections": [
{
"alias": null,
@@ -119,16 +119,16 @@ return {
]
},
"params": {
"cacheID": "debb5fda7cec88257a50998c78ee1e33",
"cacheID": "b656f027db1774647bcd0eefad36f66f",
"id": null,
"metadata": {},
"name": "ControlOverviewPageGetEvidenceFileUrlQuery",
"name": "ControlViewGetEvidenceFileUrlQuery",
"operationKind": "query",
"text": "query ControlOverviewPageGetEvidenceFileUrlQuery(\n $evidenceId: ID!\n) {\n node(id: $evidenceId) {\n __typename\n ... on Evidence {\n id\n fileUrl\n }\n id\n }\n}\n"
"text": "query ControlViewGetEvidenceFileUrlQuery(\n $evidenceId: ID!\n) {\n node(id: $evidenceId) {\n __typename\n ... on Evidence {\n id\n fileUrl\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "a97d9390a7415ff87916e268458f5ead";
(node as any).hash = "a06f8279b87274b3cec4a11f44a53f49";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<b005acefd80f6091d29b4e095f4d271c>>
* @generated SignedSource<<be19ac6afdae6f9be1b869afbd0a0838>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,10 +9,10 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlOverviewPageOrganizationQuery$variables = {
export type ControlViewOrganizationQuery$variables = {
organizationId: string;
};
export type ControlOverviewPageOrganizationQuery$data = {
export type ControlViewOrganizationQuery$data = {
readonly organization: {
readonly id: string;
readonly peoples?: {
@@ -26,9 +26,9 @@ export type ControlOverviewPageOrganizationQuery$data = {
};
};
};
export type ControlOverviewPageOrganizationQuery = {
response: ControlOverviewPageOrganizationQuery$data;
variables: ControlOverviewPageOrganizationQuery$variables;
export type ControlViewOrganizationQuery = {
response: ControlViewOrganizationQuery$data;
variables: ControlViewOrganizationQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -153,7 +153,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageOrganizationQuery",
"name": "ControlViewOrganizationQuery",
"selections": [
{
"alias": "organization",
@@ -174,10 +174,10 @@ return {
],
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "__ControlOverviewPage_peoples_connection",
"name": "__ControlView_peoples_connection",
"plural": false,
"selections": (v5/*: any*/),
"storageKey": "__ControlOverviewPage_peoples_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
"storageKey": "__ControlView_peoples_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
}
],
"type": "Organization",
@@ -194,7 +194,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageOrganizationQuery",
"name": "ControlViewOrganizationQuery",
"selections": [
{
"alias": "organization",
@@ -226,7 +226,7 @@ return {
"orderBy"
],
"handle": "connection",
"key": "ControlOverviewPage_peoples",
"key": "ControlView_peoples",
"kind": "LinkedHandle",
"name": "peoples"
}
@@ -240,7 +240,7 @@ return {
]
},
"params": {
"cacheID": "b92e9c3fbb4dcdb62652d4f9dd9f3712",
"cacheID": "9a339515a618cf48b77e74890b016272",
"id": null,
"metadata": {
"connection": [
@@ -255,13 +255,13 @@ return {
}
]
},
"name": "ControlOverviewPageOrganizationQuery",
"name": "ControlViewOrganizationQuery",
"operationKind": "query",
"text": "query ControlOverviewPageOrganizationQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\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 }\n}\n"
"text": "query ControlViewOrganizationQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\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 }\n}\n"
}
};
})();
(node as any).hash = "215186695662d7c8344e35b1f053ee5f";
(node as any).hash = "22507c997964e19a663c819b750e50c9";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<e273ee54aacd3db274341d21b33809b2>>
* @generated SignedSource<<d90309f0abd5f91c5f38c3070e27758b>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,10 +14,10 @@ export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "N
export type EvidenceState = "EXPIRED" | "INVALID" | "VALID";
export type EvidenceType = "FILE" | "LINK";
export type TaskState = "DONE" | "TODO";
export type ControlOverviewPageQuery$variables = {
export type ControlViewQuery$variables = {
controlId: string;
};
export type ControlOverviewPageQuery$data = {
export type ControlViewQuery$data = {
readonly control: {
readonly category?: string;
readonly description?: string;
@@ -61,9 +61,9 @@ export type ControlOverviewPageQuery$data = {
readonly version?: number;
};
};
export type ControlOverviewPageQuery = {
response: ControlOverviewPageQuery$data;
variables: ControlOverviewPageQuery$variables;
export type ControlViewQuery = {
response: ControlViewQuery$data;
variables: ControlViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -305,7 +305,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageQuery",
"name": "ControlViewQuery",
"selections": [
{
"alias": "control",
@@ -330,7 +330,7 @@ return {
"args": null,
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "__ControlOverviewPage_tasks_connection",
"name": "__ControlView_tasks_connection",
"plural": false,
"selections": [
{
@@ -361,7 +361,7 @@ return {
"args": null,
"concreteType": "EvidenceConnection",
"kind": "LinkedField",
"name": "__ControlOverviewPage_evidences_connection",
"name": "__ControlView_evidences_connection",
"plural": false,
"selections": (v15/*: any*/),
"storageKey": null
@@ -394,7 +394,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageQuery",
"name": "ControlViewQuery",
"selections": [
{
"alias": "control",
@@ -461,7 +461,7 @@ return {
"args": (v17/*: any*/),
"filters": null,
"handle": "connection",
"key": "ControlOverviewPage_evidences",
"key": "ControlView_evidences",
"kind": "LinkedHandle",
"name": "evidences"
},
@@ -483,7 +483,7 @@ return {
"args": (v16/*: any*/),
"filters": null,
"handle": "connection",
"key": "ControlOverviewPage_tasks",
"key": "ControlView_tasks",
"kind": "LinkedHandle",
"name": "tasks"
}
@@ -497,7 +497,7 @@ return {
]
},
"params": {
"cacheID": "6f419912ad357b36c51113ef7672ad51",
"cacheID": "35556c446c544e8b281b572272daae6b",
"id": null,
"metadata": {
"connection": [
@@ -518,13 +518,13 @@ return {
}
]
},
"name": "ControlOverviewPageQuery",
"name": "ControlViewQuery",
"operationKind": "query",
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n importance\n category\n version\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n timeEstimate\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n evidences(first: 50) {\n edges {\n node {\n id\n mimeType\n filename\n size\n state\n type\n url\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
"text": "query ControlViewQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n importance\n category\n version\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n timeEstimate\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n evidences(first: 50) {\n edges {\n node {\n id\n mimeType\n filename\n size\n state\n type\n url\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "59a0c6e5a414410be244c7a62bba7696";
(node as any).hash = "cf9ee1fdc5e0e09c6b883299af7ffa37";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<ec9f52c0ab13cd30148d7b2a97f59b63>>
* @generated SignedSource<<c9adac41c9c5084befb16e9c01a12900>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,10 +12,10 @@ import { ConcreteRequest } from 'relay-runtime';
export type UnassignTaskInput = {
taskId: string;
};
export type ControlOverviewPageUnassignTaskMutation$variables = {
export type ControlViewUnassignTaskMutation$variables = {
input: UnassignTaskInput;
};
export type ControlOverviewPageUnassignTaskMutation$data = {
export type ControlViewUnassignTaskMutation$data = {
readonly unassignTask: {
readonly task: {
readonly assignedTo: {
@@ -28,9 +28,9 @@ export type ControlOverviewPageUnassignTaskMutation$data = {
};
};
};
export type ControlOverviewPageUnassignTaskMutation = {
response: ControlOverviewPageUnassignTaskMutation$data;
variables: ControlOverviewPageUnassignTaskMutation$variables;
export type ControlViewUnassignTaskMutation = {
response: ControlViewUnassignTaskMutation$data;
variables: ControlViewUnassignTaskMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -117,7 +117,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageUnassignTaskMutation",
"name": "ControlViewUnassignTaskMutation",
"selections": (v2/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -126,20 +126,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageUnassignTaskMutation",
"name": "ControlViewUnassignTaskMutation",
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "dc570710d28f3208dbaff23593819f4d",
"cacheID": "15ebb533fa8c9ef25aba306106a198eb",
"id": null,
"metadata": {},
"name": "ControlOverviewPageUnassignTaskMutation",
"name": "ControlViewUnassignTaskMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageUnassignTaskMutation(\n $input: UnassignTaskInput!\n) {\n unassignTask(input: $input) {\n task {\n id\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n}\n"
"text": "mutation ControlViewUnassignTaskMutation(\n $input: UnassignTaskInput!\n) {\n unassignTask(input: $input) {\n task {\n id\n version\n assignedTo {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "4c809cd810a4178ab4f7a6d020338187";
(node as any).hash = "b0956d42397dc015e2ece4ff37a83857";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<f8dd4ed041b15ddf484ff9b1735c69ed>>
* @generated SignedSource<<42dcc8ca7d9ad288321e73fa5175db44>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -20,10 +20,10 @@ export type UpdateControlInput = {
name?: string | null | undefined;
state?: ControlState | null | undefined;
};
export type ControlOverviewPageUpdateControlStateMutation$variables = {
export type ControlViewUpdateControlStateMutation$variables = {
input: UpdateControlInput;
};
export type ControlOverviewPageUpdateControlStateMutation$data = {
export type ControlViewUpdateControlStateMutation$data = {
readonly updateControl: {
readonly control: {
readonly id: string;
@@ -32,9 +32,9 @@ export type ControlOverviewPageUpdateControlStateMutation$data = {
};
};
};
export type ControlOverviewPageUpdateControlStateMutation = {
response: ControlOverviewPageUpdateControlStateMutation$data;
variables: ControlOverviewPageUpdateControlStateMutation$variables;
export type ControlViewUpdateControlStateMutation = {
response: ControlViewUpdateControlStateMutation$data;
variables: ControlViewUpdateControlStateMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -101,7 +101,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageUpdateControlStateMutation",
"name": "ControlViewUpdateControlStateMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -110,20 +110,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageUpdateControlStateMutation",
"name": "ControlViewUpdateControlStateMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "f119c567d66788b2e3bed77950602ab5",
"cacheID": "b34b9c0ddec55d16efcc2f38e8334773",
"id": null,
"metadata": {},
"name": "ControlOverviewPageUpdateControlStateMutation",
"name": "ControlViewUpdateControlStateMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageUpdateControlStateMutation(\n $input: UpdateControlInput!\n) {\n updateControl(input: $input) {\n control {\n id\n state\n version\n }\n }\n}\n"
"text": "mutation ControlViewUpdateControlStateMutation(\n $input: UpdateControlInput!\n) {\n updateControl(input: $input) {\n control {\n id\n state\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "a7792a068992b6e9b0c560426b8a99a4";
(node as any).hash = "3eaaab55403bfaf343d4a740ad0128e1";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<2e946185d02bd4e3d510d81c68cb9863>>
* @generated SignedSource<<70ae17b69b366175da4d00bfae78ae7f>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -18,10 +18,10 @@ export type UpdateTaskInput = {
taskId: string;
timeEstimate?: any | null | undefined;
};
export type ControlOverviewPageUpdateTaskStateMutation$variables = {
export type ControlViewUpdateTaskStateMutation$variables = {
input: UpdateTaskInput;
};
export type ControlOverviewPageUpdateTaskStateMutation$data = {
export type ControlViewUpdateTaskStateMutation$data = {
readonly updateTask: {
readonly task: {
readonly id: string;
@@ -30,9 +30,9 @@ export type ControlOverviewPageUpdateTaskStateMutation$data = {
};
};
};
export type ControlOverviewPageUpdateTaskStateMutation = {
response: ControlOverviewPageUpdateTaskStateMutation$data;
variables: ControlOverviewPageUpdateTaskStateMutation$variables;
export type ControlViewUpdateTaskStateMutation = {
response: ControlViewUpdateTaskStateMutation$data;
variables: ControlViewUpdateTaskStateMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -99,7 +99,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageUpdateTaskStateMutation",
"name": "ControlViewUpdateTaskStateMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -108,20 +108,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageUpdateTaskStateMutation",
"name": "ControlViewUpdateTaskStateMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "4ebc7b830cc1c2129ec26429ab681c44",
"cacheID": "1b9a74365007e7c46668815c9e37cd0a",
"id": null,
"metadata": {},
"name": "ControlOverviewPageUpdateTaskStateMutation",
"name": "ControlViewUpdateTaskStateMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageUpdateTaskStateMutation(\n $input: UpdateTaskInput!\n) {\n updateTask(input: $input) {\n task {\n id\n state\n version\n }\n }\n}\n"
"text": "mutation ControlViewUpdateTaskStateMutation(\n $input: UpdateTaskInput!\n) {\n updateTask(input: $input) {\n task {\n id\n state\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "9a076b4306fee5793009ea3082e5cde2";
(node as any).hash = "7cb2f42aadcc5f709377d2bf9dfea2da";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<6726ce2446381674b1992ece913fd680>>
* @generated SignedSource<<f6ace4744710269a7c6502726617efcb>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -19,11 +19,11 @@ export type UploadEvidenceInput = {
type: EvidenceType;
url?: string | null | undefined;
};
export type ControlOverviewPageUploadEvidenceMutation$variables = {
export type ControlViewUploadEvidenceMutation$variables = {
connections: ReadonlyArray<string>;
input: UploadEvidenceInput;
};
export type ControlOverviewPageUploadEvidenceMutation$data = {
export type ControlViewUploadEvidenceMutation$data = {
readonly uploadEvidence: {
readonly evidenceEdge: {
readonly node: {
@@ -40,9 +40,9 @@ export type ControlOverviewPageUploadEvidenceMutation$data = {
};
};
};
export type ControlOverviewPageUploadEvidenceMutation = {
response: ControlOverviewPageUploadEvidenceMutation$data;
variables: ControlOverviewPageUploadEvidenceMutation$variables;
export type ControlViewUploadEvidenceMutation = {
response: ControlViewUploadEvidenceMutation$data;
variables: ControlViewUploadEvidenceMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -156,7 +156,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageUploadEvidenceMutation",
"name": "ControlViewUploadEvidenceMutation",
"selections": [
{
"alias": null,
@@ -181,7 +181,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "ControlOverviewPageUploadEvidenceMutation",
"name": "ControlViewUploadEvidenceMutation",
"selections": [
{
"alias": null,
@@ -214,16 +214,16 @@ return {
]
},
"params": {
"cacheID": "de3681706266cd453ddccb5fe2900ea8",
"cacheID": "62fbef44afc988cdedc94c7a0292a432",
"id": null,
"metadata": {},
"name": "ControlOverviewPageUploadEvidenceMutation",
"name": "ControlViewUploadEvidenceMutation",
"operationKind": "mutation",
"text": "mutation ControlOverviewPageUploadEvidenceMutation(\n $input: UploadEvidenceInput!\n) {\n uploadEvidence(input: $input) {\n evidenceEdge {\n node {\n id\n filename\n fileUrl\n mimeType\n type\n url\n size\n state\n createdAt\n }\n }\n }\n}\n"
"text": "mutation ControlViewUploadEvidenceMutation(\n $input: UploadEvidenceInput!\n) {\n uploadEvidence(input: $input) {\n evidenceEdge {\n node {\n id\n filename\n fileUrl\n mimeType\n type\n url\n size\n state\n createdAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "396146017c07e06dc709199a0b5ad012";
(node as any).hash = "1e2b47702f9a03efdca7fdfd44ebbccf";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<6a4f7190379c0f273e97cedf6aebe517>>
* @generated SignedSource<<a38544ace1fb4fc6da394e8ea2c35787>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -18,11 +18,11 @@ export type CreateControlInput = {
importance: ControlImportance;
name: string;
};
export type CreateControlPageCreateControlMutation$variables = {
export type CreateControlViewCreateControlMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateControlInput;
};
export type CreateControlPageCreateControlMutation$data = {
export type CreateControlViewCreateControlMutation$data = {
readonly createControl: {
readonly controlEdge: {
readonly node: {
@@ -35,9 +35,9 @@ export type CreateControlPageCreateControlMutation$data = {
};
};
};
export type CreateControlPageCreateControlMutation = {
response: CreateControlPageCreateControlMutation$data;
variables: CreateControlPageCreateControlMutation$variables;
export type CreateControlViewCreateControlMutation = {
response: CreateControlViewCreateControlMutation$data;
variables: CreateControlViewCreateControlMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -123,7 +123,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "CreateControlPageCreateControlMutation",
"name": "CreateControlViewCreateControlMutation",
"selections": [
{
"alias": null,
@@ -148,7 +148,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreateControlPageCreateControlMutation",
"name": "CreateControlViewCreateControlMutation",
"selections": [
{
"alias": null,
@@ -181,16 +181,16 @@ return {
]
},
"params": {
"cacheID": "c6b6a979bccf19dd57123c49d5ce738b",
"cacheID": "f0f7112f1eb67ed8e7f0fe38b427b0c5",
"id": null,
"metadata": {},
"name": "CreateControlPageCreateControlMutation",
"name": "CreateControlViewCreateControlMutation",
"operationKind": "mutation",
"text": "mutation CreateControlPageCreateControlMutation(\n $input: CreateControlInput!\n) {\n createControl(input: $input) {\n controlEdge {\n node {\n id\n name\n description\n category\n state\n }\n }\n }\n}\n"
"text": "mutation CreateControlViewCreateControlMutation(\n $input: CreateControlInput!\n) {\n createControl(input: $input) {\n controlEdge {\n node {\n id\n name\n description\n category\n state\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "4f8f339056a2f83c78ce3a642fa6ecc6";
(node as any).hash = "b9b19ec5600f99a2a8e75e0bd9f86355";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<86f7062f21c125e478c5a7a58b495d6c>>
* @generated SignedSource<<839284c6c29a5727d0ca521b1d804c5f>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,10 +11,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type ControlImportance = "ADVANCED" | "MANDATORY" | "PREFERRED";
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type UpdateControlPageQuery$variables = {
export type UpdateControlViewQuery$variables = {
controlId: string;
};
export type UpdateControlPageQuery$data = {
export type UpdateControlViewQuery$data = {
readonly node: {
readonly category?: string;
readonly description?: string;
@@ -25,9 +25,9 @@ export type UpdateControlPageQuery$data = {
readonly version?: number;
};
};
export type UpdateControlPageQuery = {
response: UpdateControlPageQuery$data;
variables: UpdateControlPageQuery$variables;
export type UpdateControlViewQuery = {
response: UpdateControlViewQuery$data;
variables: UpdateControlViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -99,7 +99,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateControlPageQuery",
"name": "UpdateControlViewQuery",
"selections": [
{
"alias": null,
@@ -134,7 +134,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateControlPageQuery",
"name": "UpdateControlViewQuery",
"selections": [
{
"alias": null,
@@ -171,16 +171,16 @@ return {
]
},
"params": {
"cacheID": "8b9ee499b6fc5726d6ce679205b59501",
"cacheID": "d578024453f1901119a772154524070d",
"id": null,
"metadata": {},
"name": "UpdateControlPageQuery",
"name": "UpdateControlViewQuery",
"operationKind": "query",
"text": "query UpdateControlPageQuery(\n $controlId: ID!\n) {\n node(id: $controlId) {\n __typename\n ... on Control {\n id\n name\n description\n category\n importance\n state\n version\n }\n id\n }\n}\n"
"text": "query UpdateControlViewQuery(\n $controlId: ID!\n) {\n node(id: $controlId) {\n __typename\n ... on Control {\n id\n name\n description\n category\n importance\n state\n version\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "f3336cff42059e52e983e4cd9d6c2f1e";
(node as any).hash = "e6a997a27a8e45c01ad415e73b794cae";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<8eaa34f32a83e626da3331f2e0c6a7cf>>
* @generated SignedSource<<5223ef96f75ea5f5d4b124d190dfd455>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -20,10 +20,10 @@ export type UpdateControlInput = {
name?: string | null | undefined;
state?: ControlState | null | undefined;
};
export type UpdateControlPageUpdateControlMutation$variables = {
export type UpdateControlViewUpdateControlMutation$variables = {
input: UpdateControlInput;
};
export type UpdateControlPageUpdateControlMutation$data = {
export type UpdateControlViewUpdateControlMutation$data = {
readonly updateControl: {
readonly control: {
readonly category: string;
@@ -36,9 +36,9 @@ export type UpdateControlPageUpdateControlMutation$data = {
};
};
};
export type UpdateControlPageUpdateControlMutation = {
response: UpdateControlPageUpdateControlMutation$data;
variables: UpdateControlPageUpdateControlMutation$variables;
export type UpdateControlViewUpdateControlMutation = {
response: UpdateControlViewUpdateControlMutation$data;
variables: UpdateControlViewUpdateControlMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -133,7 +133,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateControlPageUpdateControlMutation",
"name": "UpdateControlViewUpdateControlMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -142,20 +142,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateControlPageUpdateControlMutation",
"name": "UpdateControlViewUpdateControlMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "3d05aa40f4dba0b24241235c54b85c4f",
"cacheID": "1702f16b6cc6452422c0f72acee25359",
"id": null,
"metadata": {},
"name": "UpdateControlPageUpdateControlMutation",
"name": "UpdateControlViewUpdateControlMutation",
"operationKind": "mutation",
"text": "mutation UpdateControlPageUpdateControlMutation(\n $input: UpdateControlInput!\n) {\n updateControl(input: $input) {\n control {\n id\n name\n description\n category\n importance\n state\n version\n }\n }\n}\n"
"text": "mutation UpdateControlViewUpdateControlMutation(\n $input: UpdateControlInput!\n) {\n updateControl(input: $input) {\n control {\n id\n name\n description\n category\n importance\n state\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "0de0b5b9560213e95af60cc1732dee4c";
(node as any).hash = "e0f0940fe564406ef95473420b336128";
export default node;

View File

@@ -1,301 +1,11 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreatePeoplePageCreatePeopleMutation } from "./__generated__/CreatePeoplePageCreatePeopleMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const createPeopleMutation = graphql`
mutation CreatePeoplePageCreatePeopleMutation(
$input: CreatePeopleInput!
$connections: [ID!]!
) {
createPeople(input: $input) {
peopleEdge @prependEdge(connections: $connections) {
node {
id
fullName
primaryEmailAddress
additionalEmailAddresses
kind
}
}
}
}
`;
const CreatePeopleView = lazy(() => import("./CreatePeopleView"));
function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">{label}</Label>
</div>
<div className="space-y-2">
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
/>
{helpText && <p className="text-sm text-gray-500">{helpText}</p>}
</div>
</div>
);
}
function CreatePeoplePageContent() {
const navigate = useNavigate();
const { organizationId } = useParams();
const [createPeople] =
useMutation<CreatePeoplePageCreatePeopleMutation>(createPeopleMutation);
const { toast } = useToast();
const [formData, setFormData] = useState({
fullName: "",
primaryEmailAddress: "",
additionalEmailAddresses: [] as string[],
kind: "EMPLOYEE" as "EMPLOYEE" | "CONTRACTOR" | "SERVICE_ACCOUNT",
});
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createPeople({
variables: {
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"PeopleListPage_peoples",
{
orderBy: {
direction: "ASC",
field: "FULL_NAME",
},
}
),
ConnectionHandler.getConnectionID(
organizationId!,
"PeopleSelector_organization_peoples",
{
orderBy: {
direction: "ASC",
field: "FULL_NAME",
},
}
),
ConnectionHandler.getConnectionID(
organizationId!,
"ControlOverviewPage_peoples",
{
orderBy: {
direction: "ASC",
field: "FULL_NAME",
},
}
),
],
input: {
organizationId: organizationId!,
fullName: formData.fullName,
primaryEmailAddress: formData.primaryEmailAddress,
additionalEmailAddresses: formData.additionalEmailAddresses,
kind: formData.kind,
},
},
onCompleted: () => {
toast({
title: "Success",
description: "Person created successfully",
variant: "default",
});
navigate(`/organizations/${organizationId}/people`);
},
onError: (error) => {
toast({
title: "Error",
description: error.message || "Failed to create person",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Person"
description="Add a new person interacting with organization"
>
<form onSubmit={handleSubmit}>
<div className="max-w-2xl space-y-6">
<EditableField
label="Full Name"
value={formData.fullName}
onChange={(value) => handleFieldChange("fullName", value)}
required
/>
<EditableField
label="Primary Email"
value={formData.primaryEmailAddress}
type="email"
onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
required
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Additional Email Addresses</Label>
</div>
<div className="space-y-2">
{formData.additionalEmailAddresses.map((email, index) => (
<div key={index} className="flex gap-2">
<Input
type="email"
value={email}
onChange={(e) => {
const newEmails = [...formData.additionalEmailAddresses];
newEmails[index] = e.target.value;
handleFieldChange("additionalEmailAddresses", newEmails);
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
);
handleFieldChange("additionalEmailAddresses", newEmails);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}}
>
Add Email
</Button>
</div>
</div>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">Additional Information</h2>
<p className="text-sm text-gray-500">
Additional details about the person
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Kind</Label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Employee
</button>
<button
type="button"
onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Contractor
</button>
<button
type="button"
onClick={() =>
handleFieldChange("kind", "SERVICE_ACCOUNT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Service Account
</button>
</div>
</div>
</div>
</div>
</Card>
</div>
<div className="fixed bottom-6 right-6 flex gap-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Person
</Button>
</div>
</form>
</PageTemplate>
);
}
export function CreatePeoplePageSkeleton() {
export function CreatePeopleViewSkeleton() {
return (
<PageTemplateSkeleton
title="Create Person"
@@ -306,10 +16,14 @@ export function CreatePeoplePageSkeleton() {
);
}
export default function CreatePeoplePage() {
export function CreatePeoplePage() {
const location = useLocation();
return (
<Suspense fallback={<CreatePeoplePageSkeleton />}>
<CreatePeoplePageContent />
<Suspense key={location.pathname} fallback={<CreatePeopleViewSkeleton />}>
<ErrorBoundaryWithLocation>
<CreatePeopleView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,305 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreatePeopleViewCreatePeopleMutation } from "./__generated__/CreatePeopleViewCreatePeopleMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { CreatePeopleViewSkeleton } from "./CreatePeoplePage";
const createPeopleMutation = graphql`
mutation CreatePeopleViewCreatePeopleMutation(
$input: CreatePeopleInput!
$connections: [ID!]!
) {
createPeople(input: $input) {
peopleEdge @prependEdge(connections: $connections) {
node {
id
fullName
primaryEmailAddress
additionalEmailAddresses
kind
}
}
}
}
`;
function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">{label}</Label>
</div>
<div className="space-y-2">
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
/>
{helpText && <p className="text-sm text-gray-500">{helpText}</p>}
</div>
</div>
);
}
function CreatePeopleViewContent() {
const navigate = useNavigate();
const { organizationId } = useParams();
const [createPeople] =
useMutation<CreatePeopleViewCreatePeopleMutation>(createPeopleMutation);
const { toast } = useToast();
const [formData, setFormData] = useState({
fullName: "",
primaryEmailAddress: "",
additionalEmailAddresses: [] as string[],
kind: "EMPLOYEE" as "EMPLOYEE" | "CONTRACTOR" | "SERVICE_ACCOUNT",
});
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createPeople({
variables: {
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"PeopleListPage_peoples",
{
orderBy: {
direction: "ASC",
field: "FULL_NAME",
},
}
),
ConnectionHandler.getConnectionID(
organizationId!,
"PeopleSelector_organization_peoples",
{
orderBy: {
direction: "ASC",
field: "FULL_NAME",
},
}
),
ConnectionHandler.getConnectionID(
organizationId!,
"ControlOverviewPage_peoples",
{
orderBy: {
direction: "ASC",
field: "FULL_NAME",
},
}
),
],
input: {
organizationId: organizationId!,
fullName: formData.fullName,
primaryEmailAddress: formData.primaryEmailAddress,
additionalEmailAddresses: formData.additionalEmailAddresses,
kind: formData.kind,
},
},
onCompleted: () => {
toast({
title: "Success",
description: "Person created successfully",
variant: "default",
});
navigate(`/organizations/${organizationId}/people`);
},
onError: (error) => {
toast({
title: "Error",
description: error.message || "Failed to create person",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Person"
description="Add a new person interacting with organization"
>
<form onSubmit={handleSubmit}>
<div className="max-w-2xl space-y-6">
<EditableField
label="Full Name"
value={formData.fullName}
onChange={(value) => handleFieldChange("fullName", value)}
required
/>
<EditableField
label="Primary Email"
value={formData.primaryEmailAddress}
type="email"
onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
required
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Additional Email Addresses</Label>
</div>
<div className="space-y-2">
{formData.additionalEmailAddresses.map((email, index) => (
<div key={index} className="flex gap-2">
<Input
type="email"
value={email}
onChange={(e) => {
const newEmails = [...formData.additionalEmailAddresses];
newEmails[index] = e.target.value;
handleFieldChange("additionalEmailAddresses", newEmails);
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
);
handleFieldChange("additionalEmailAddresses", newEmails);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}}
>
Add Email
</Button>
</div>
</div>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">Additional Information</h2>
<p className="text-sm text-gray-500">
Additional details about the person
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Kind</Label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Employee
</button>
<button
type="button"
onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Contractor
</button>
<button
type="button"
onClick={() =>
handleFieldChange("kind", "SERVICE_ACCOUNT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Service Account
</button>
</div>
</div>
</div>
</div>
</Card>
</div>
<div className="fixed bottom-6 right-6 flex gap-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Person
</Button>
</div>
</form>
</PageTemplate>
);
}
export default function CreatePeopleView() {
return (
<Suspense fallback={<CreatePeopleViewSkeleton />}>
<CreatePeopleViewContent />
</Suspense>
);
}

View File

@@ -1,299 +1,11 @@
import { Suspense, useEffect, useTransition } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
usePaginationFragment,
} from "react-relay";
import { useSearchParams, useParams } from "react-router";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { UserPlus, Trash2, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import type { PeopleListPageQuery as PeopleListPageQueryType } from "./__generated__/PeopleListPageQuery.graphql";
import type { PeopleListPageDeletePeopleMutation } from "./__generated__/PeopleListPageDeletePeopleMutation.graphql";
import { PeopleListPagePaginationQuery } from "./__generated__/PeopleListPagePaginationQuery.graphql";
import { PeopleListPage_peoples$key } from "./__generated__/PeopleListPage_peoples.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const ITEMS_PER_PAGE = 25;
const PeopleListView = lazy(() => import("./PeopleListView"));
const peopleListPageQuery = graphql`
query PeopleListPageQuery(
$organizationId: ID!
$first: Int
$after: CursorKey
$last: Int
$before: CursorKey
) {
organization: node(id: $organizationId) {
...PeopleListPage_peoples
@arguments(first: $first, after: $after, last: $last, before: $before)
}
}
`;
const peopleListFragment = graphql`
fragment PeopleListPage_peoples on Organization
@refetchable(queryName: "PeopleListPagePaginationQuery")
@argumentDefinitions(
first: { type: "Int" }
after: { type: "CursorKey" }
last: { type: "Int" }
before: { type: "CursorKey" }
) {
id
peoples(
first: $first
after: $after
last: $last
before: $before
orderBy: { direction: ASC, field: FULL_NAME }
) @connection(key: "PeopleListPage_peoples") {
__id
edges {
node {
id
fullName
primaryEmailAddress
additionalEmailAddresses
kind
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
const deletePeopleMutation = graphql`
mutation PeopleListPageDeletePeopleMutation(
$input: DeletePeopleInput!
$connections: [ID!]!
) {
deletePeople(input: $input) {
deletedPeopleId @deleteEdge(connections: $connections)
}
}
`;
function LoadAboveButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading}
className="w-full"
>
{isLoading ? "Loading..." : "Load above"}
</Button>
</div>
);
}
function LoadBelowButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load below"}
</Button>
</div>
);
}
function PeopleListContent({
queryRef,
}: {
queryRef: PreloadedQuery<PeopleListPageQueryType>;
}) {
const data = usePreloadedQuery<PeopleListPageQueryType>(
peopleListPageQuery,
queryRef
);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [deletePeople] =
useMutation<PeopleListPageDeletePeopleMutation>(deletePeopleMutation);
const { organizationId } = useParams();
const {
data: peoplesConnection,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment<
PeopleListPagePaginationQuery,
PeopleListPage_peoples$key
>(peopleListFragment, data.organization);
const peoples =
peoplesConnection.peoples.edges.map((edge) => edge.node) ?? [];
const pageInfo = peoplesConnection.peoples.pageInfo;
return (
<PageTemplate
title="People"
description="Keep track of your company's workforce and their progress
towards completing tasks assigned to them."
actions={
<Button
asChild
variant="outline"
style={{ borderRadius: "0.5rem" }}
className="gap-2"
>
<Link to={`/organizations/${organizationId}/people/create`}>
<UserPlus className="h-4 w-4" />
Add a person
</Link>
</Button>
}
>
<div className="space-y-6">
<div className="space-y-2">
{peoples.map((person) => (
<Link
key={person?.id}
to={`/organizations/${organizationId}/people/${person?.id}`}
className="block"
>
<div className="flex items-center justify-between p-4 rounded-xl border bg-card hover:bg-accent/5 transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{person?.fullName?.[0]}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<p className="font-medium">{person?.fullName}</p>
{person?.primaryEmailAddress && (
<>
<span className="text-muted-foreground">•</span>
<p className="text-sm text-muted-foreground">
{person.primaryEmailAddress}
</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className="bg-lime-9 text-white rounded-full px-3 py-0.5 text-xs font-medium"
>
{person?.kind === "EMPLOYEE"
? "Employee"
: person?.kind === "CONTRACTOR"
? "Contractor"
: person?.kind === "SERVICE_ACCOUNT"
? "Service Account"
: "Vendor"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:[&>svg]:text-destructive"
onClick={(e) => {
e.preventDefault();
if (
window.confirm(
"Are you sure you want to delete this person?"
)
) {
deletePeople({
variables: {
connections: [peoplesConnection.peoples.__id],
input: {
peopleId: person.id,
},
},
});
}
}}
>
<Trash2 className="h-4 w-4 transition-colors" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</Link>
))}
</div>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
</div>
</PageTemplate>
);
}
export function PeopleListPageSkeleton() {
export function PeopleListViewSkeleton() {
return (
<PageTemplateSkeleton
title="People"
@@ -318,33 +30,14 @@ export function PeopleListPageSkeleton() {
);
}
export default function PeopleListPage() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<PeopleListPageQueryType>(peopleListPageQuery);
const { organizationId } = useParams();
useEffect(() => {
const after = searchParams.get("after");
const before = searchParams.get("before");
loadQuery({
organizationId: organizationId!,
first: before ? undefined : ITEMS_PER_PAGE,
after: after || undefined,
last: before ? ITEMS_PER_PAGE : undefined,
before: before || undefined,
});
}, [loadQuery, organizationId, searchParams]);
if (!queryRef) {
return <PeopleListPageSkeleton />;
}
export function PeopleListPage() {
const location = useLocation();
return (
<Suspense fallback={<PeopleListPageSkeleton />}>
<PeopleListContent queryRef={queryRef} />
<Suspense key={location.pathname} fallback={<PeopleListViewSkeleton />}>
<ErrorBoundaryWithLocation>
<PeopleListView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,326 @@
import { Suspense, useEffect, useTransition } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
usePaginationFragment,
} from "react-relay";
import { useSearchParams, useParams } from "react-router";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { UserPlus, Trash2, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import type { PeopleListViewQuery as PeopleListViewQueryType } from "./__generated__/PeopleListViewQuery.graphql";
import type { PeopleListViewDeletePeopleMutation } from "./__generated__/PeopleListViewDeletePeopleMutation.graphql";
import { PeopleListViewPaginationQuery } from "./__generated__/PeopleListViewPaginationQuery.graphql";
import { PeopleListView_peoples$key } from "./__generated__/PeopleListView_peoples.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { PeopleListViewSkeleton } from "./PeopleListPage";
const ITEMS_PER_PAGE = 25;
const peopleListViewQuery = graphql`
query PeopleListViewQuery(
$organizationId: ID!
$first: Int
$after: CursorKey
$last: Int
$before: CursorKey
) {
organization: node(id: $organizationId) {
...PeopleListView_peoples
@arguments(first: $first, after: $after, last: $last, before: $before)
}
}
`;
const peopleListFragment = graphql`
fragment PeopleListView_peoples on Organization
@refetchable(queryName: "PeopleListViewPaginationQuery")
@argumentDefinitions(
first: { type: "Int" }
after: { type: "CursorKey" }
last: { type: "Int" }
before: { type: "CursorKey" }
) {
id
peoples(
first: $first
after: $after
last: $last
before: $before
orderBy: { direction: ASC, field: FULL_NAME }
) @connection(key: "PeopleListView_peoples") {
__id
edges {
node {
id
fullName
primaryEmailAddress
additionalEmailAddresses
kind
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
const deletePeopleMutation = graphql`
mutation PeopleListViewDeletePeopleMutation(
$input: DeletePeopleInput!
$connections: [ID!]!
) {
deletePeople(input: $input) {
deletedPeopleId @deleteEdge(connections: $connections)
}
}
`;
function LoadAboveButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading}
className="w-full"
>
{isLoading ? "Loading..." : "Load above"}
</Button>
</div>
);
}
function LoadBelowButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load below"}
</Button>
</div>
);
}
function PeopleListContent({
queryRef,
}: {
queryRef: PreloadedQuery<PeopleListViewQueryType>;
}) {
const data = usePreloadedQuery<PeopleListViewQueryType>(
peopleListViewQuery,
queryRef
);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [deletePeople] =
useMutation<PeopleListViewDeletePeopleMutation>(deletePeopleMutation);
const { organizationId } = useParams();
const {
data: peoplesConnection,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment<
PeopleListViewPaginationQuery,
PeopleListView_peoples$key
>(peopleListFragment, data.organization);
const peoples =
peoplesConnection.peoples.edges.map((edge) => edge.node) ?? [];
const pageInfo = peoplesConnection.peoples.pageInfo;
return (
<PageTemplate
title="People"
description="Keep track of your company's workforce and their progress
towards completing tasks assigned to them."
actions={
<Button
asChild
variant="outline"
style={{ borderRadius: "0.5rem" }}
className="gap-2"
>
<Link to={`/organizations/${organizationId}/people/create`}>
<UserPlus className="h-4 w-4" />
Add a person
</Link>
</Button>
}
>
<div className="space-y-6">
<div className="space-y-2">
{peoples.map((person) => (
<Link
key={person?.id}
to={`/organizations/${organizationId}/people/${person?.id}`}
className="block"
>
<div className="flex items-center justify-between p-4 rounded-xl border bg-card hover:bg-accent/5 transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{person?.fullName?.[0]}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<p className="font-medium">{person?.fullName}</p>
{person?.primaryEmailAddress && (
<>
<span className="text-muted-foreground">•</span>
<p className="text-sm text-muted-foreground">
{person.primaryEmailAddress}
</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className="bg-lime-9 text-white rounded-full px-3 py-0.5 text-xs font-medium"
>
{person?.kind === "EMPLOYEE"
? "Employee"
: person?.kind === "CONTRACTOR"
? "Contractor"
: person?.kind === "SERVICE_ACCOUNT"
? "Service Account"
: "Vendor"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:[&>svg]:text-destructive"
onClick={(e) => {
e.preventDefault();
if (
window.confirm(
"Are you sure you want to delete this person?"
)
) {
deletePeople({
variables: {
connections: [peoplesConnection.peoples.__id],
input: {
peopleId: person.id,
},
},
});
}
}}
>
<Trash2 className="h-4 w-4 transition-colors" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</Link>
))}
</div>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
</div>
</PageTemplate>
);
}
export default function PeopleListView() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<PeopleListViewQueryType>(peopleListViewQuery);
const { organizationId } = useParams();
useEffect(() => {
const after = searchParams.get("after");
const before = searchParams.get("before");
loadQuery({
organizationId: organizationId!,
first: before ? undefined : ITEMS_PER_PAGE,
after: after || undefined,
last: before ? ITEMS_PER_PAGE : undefined,
before: before || undefined,
});
}, [loadQuery, organizationId, searchParams]);
if (!queryRef) {
return <PeopleListViewSkeleton />;
}
return (
<Suspense fallback={<PeopleListViewSkeleton />}>
<PeopleListContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,36 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const PeopleView = lazy(() => import("./PeopleView"));
export function PeopleViewSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
export function PeoplePage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<PeopleViewSkeleton />}>
<ErrorBoundaryWithLocation>
<PeopleView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -14,13 +14,14 @@ import {
useMutation,
} from "react-relay";
import { Suspense, useEffect, useState, useCallback } from "react";
import type { PeopleOverviewPageQuery as PeopleOverviewPageQueryType } from "./__generated__/PeopleOverviewPageQuery.graphql";
import type { PeopleViewQuery as PeopleViewQueryType } from "./__generated__/PeopleViewQuery.graphql";
import { useParams } from "react-router";
import { cn } from "@/lib/utils";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplate } from "@/components/PageTemplate";
import { PeopleViewSkeleton } from "./PeoplePage";
const peopleOverviewPageQuery = graphql`
query PeopleOverviewPageQuery($peopleId: ID!) {
const peopleViewQuery = graphql`
query PeopleViewQuery($peopleId: ID!) {
node(id: $peopleId) {
... on People {
id
@@ -37,7 +38,7 @@ const peopleOverviewPageQuery = graphql`
`;
const updatePeopleMutation = graphql`
mutation PeopleOverviewPageUpdatePeopleMutation($input: UpdatePeopleInput!) {
mutation PeopleViewUpdatePeopleMutation($input: UpdatePeopleInput!) {
updatePeople(input: $input) {
people {
id
@@ -83,12 +84,12 @@ function EditableField({
);
}
function PeopleOverviewPageContent({
function PeopleViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<PeopleOverviewPageQueryType>;
queryRef: PreloadedQuery<PeopleViewQueryType>;
}) {
const data = usePreloadedQuery(peopleOverviewPageQuery, queryRef);
const data = usePreloadedQuery(peopleViewQuery, queryRef);
const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
fullName: data.node.fullName || "",
@@ -97,9 +98,7 @@ function PeopleOverviewPageContent({
kind: data.node.kind,
});
const [commit] = useMutation(updatePeopleMutation);
const [, loadQuery] = useQueryLoader<PeopleOverviewPageQueryType>(
peopleOverviewPageQuery
);
const [, loadQuery] = useQueryLoader<PeopleViewQueryType>(peopleViewQuery);
const { toast } = useToast();
const hasChanges = editedFields.size > 0;
@@ -299,41 +298,22 @@ function PeopleOverviewPageContent({
);
}
export function PeopleOverviewPageSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
export default function PeopleOverviewPage() {
export default function PeopleView() {
const { peopleId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<PeopleOverviewPageQueryType>(
peopleOverviewPageQuery
);
const [queryRef, loadQuery] =
useQueryLoader<PeopleViewQueryType>(peopleViewQuery);
useEffect(() => {
loadQuery({ peopleId: peopleId! });
}, [loadQuery, peopleId]);
if (!queryRef) {
return <PeopleOverviewPageSkeleton />;
return <PeopleViewSkeleton />;
}
return (
<Suspense fallback={<PeopleOverviewPageSkeleton />}>
<PeopleOverviewPageContent queryRef={queryRef} />
<Suspense fallback={<PeopleViewSkeleton />}>
<PeopleViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<6c8122e298c594879d1cd54bf297c11b>>
* @generated SignedSource<<eb07431152c49241a6e636984bbd5bee>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -17,11 +17,11 @@ export type CreatePeopleInput = {
organizationId: string;
primaryEmailAddress: string;
};
export type CreatePeoplePageCreatePeopleMutation$variables = {
export type CreatePeopleViewCreatePeopleMutation$variables = {
connections: ReadonlyArray<string>;
input: CreatePeopleInput;
};
export type CreatePeoplePageCreatePeopleMutation$data = {
export type CreatePeopleViewCreatePeopleMutation$data = {
readonly createPeople: {
readonly peopleEdge: {
readonly node: {
@@ -34,9 +34,9 @@ export type CreatePeoplePageCreatePeopleMutation$data = {
};
};
};
export type CreatePeoplePageCreatePeopleMutation = {
response: CreatePeoplePageCreatePeopleMutation$data;
variables: CreatePeoplePageCreatePeopleMutation$variables;
export type CreatePeopleViewCreatePeopleMutation = {
response: CreatePeopleViewCreatePeopleMutation$data;
variables: CreatePeopleViewCreatePeopleMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -122,7 +122,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "CreatePeoplePageCreatePeopleMutation",
"name": "CreatePeopleViewCreatePeopleMutation",
"selections": [
{
"alias": null,
@@ -147,7 +147,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreatePeoplePageCreatePeopleMutation",
"name": "CreatePeopleViewCreatePeopleMutation",
"selections": [
{
"alias": null,
@@ -180,16 +180,16 @@ return {
]
},
"params": {
"cacheID": "3dc15f5a577685faa1c20d26a7f1d716",
"cacheID": "f09d8f7be5234f21e7bfabecf8d8b0af",
"id": null,
"metadata": {},
"name": "CreatePeoplePageCreatePeopleMutation",
"name": "CreatePeopleViewCreatePeopleMutation",
"operationKind": "mutation",
"text": "mutation CreatePeoplePageCreatePeopleMutation(\n $input: CreatePeopleInput!\n) {\n createPeople(input: $input) {\n peopleEdge {\n node {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n }\n }\n }\n}\n"
"text": "mutation CreatePeopleViewCreatePeopleMutation(\n $input: CreatePeopleInput!\n) {\n createPeople(input: $input) {\n peopleEdge {\n node {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "5ac3b2fbe80bf8a35d5595c6658a39cc";
(node as any).hash = "ac8a85af6d2902de6f475588679ba038";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c4cdffc060744bb3853074ed3b123f61>>
* @generated SignedSource<<0602531d8b8a402e73b4823e6393da31>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
export type DeletePeopleInput = {
peopleId: string;
};
export type PeopleListPageDeletePeopleMutation$variables = {
export type PeopleListViewDeletePeopleMutation$variables = {
connections: ReadonlyArray<string>;
input: DeletePeopleInput;
};
export type PeopleListPageDeletePeopleMutation$data = {
export type PeopleListViewDeletePeopleMutation$data = {
readonly deletePeople: {
readonly deletedPeopleId: string;
};
};
export type PeopleListPageDeletePeopleMutation = {
response: PeopleListPageDeletePeopleMutation$data;
variables: PeopleListPageDeletePeopleMutation$variables;
export type PeopleListViewDeletePeopleMutation = {
response: PeopleListViewDeletePeopleMutation$data;
variables: PeopleListViewDeletePeopleMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -59,7 +59,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "PeopleListPageDeletePeopleMutation",
"name": "PeopleListViewDeletePeopleMutation",
"selections": [
{
"alias": null,
@@ -84,7 +84,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "PeopleListPageDeletePeopleMutation",
"name": "PeopleListViewDeletePeopleMutation",
"selections": [
{
"alias": null,
@@ -117,16 +117,16 @@ return {
]
},
"params": {
"cacheID": "977639987276708b1e4758508eaaf439",
"cacheID": "2b9ad65c15101fb4e421c78ca4fd871c",
"id": null,
"metadata": {},
"name": "PeopleListPageDeletePeopleMutation",
"name": "PeopleListViewDeletePeopleMutation",
"operationKind": "mutation",
"text": "mutation PeopleListPageDeletePeopleMutation(\n $input: DeletePeopleInput!\n) {\n deletePeople(input: $input) {\n deletedPeopleId\n }\n}\n"
"text": "mutation PeopleListViewDeletePeopleMutation(\n $input: DeletePeopleInput!\n) {\n deletePeople(input: $input) {\n deletedPeopleId\n }\n}\n"
}
};
})();
(node as any).hash = "6552abc6c02cdc2c8e84ebbce1eb51f1";
(node as any).hash = "cc33cd4710c915cec66c217c010d728b";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<7976ca2ca06bb48d6dd6a30b1182ed02>>
* @generated SignedSource<<6aff6203f26803074cd1ea539096cbed>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,21 +10,21 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PeopleListPagePaginationQuery$variables = {
export type PeopleListViewPaginationQuery$variables = {
after?: string | null | undefined;
before?: string | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
};
export type PeopleListPagePaginationQuery$data = {
export type PeopleListViewPaginationQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleListPage_peoples">;
readonly " $fragmentSpreads": FragmentRefs<"PeopleListView_peoples">;
};
};
export type PeopleListPagePaginationQuery = {
response: PeopleListPagePaginationQuery$data;
variables: PeopleListPagePaginationQuery$variables;
export type PeopleListViewPaginationQuery = {
response: PeopleListViewPaginationQuery$data;
variables: PeopleListViewPaginationQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -119,7 +119,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "PeopleListPagePaginationQuery",
"name": "PeopleListViewPaginationQuery",
"selections": [
{
"alias": null,
@@ -137,7 +137,7 @@ return {
(v9/*: any*/)
],
"kind": "FragmentSpread",
"name": "PeopleListPage_peoples"
"name": "PeopleListView_peoples"
}
],
"storageKey": null
@@ -156,7 +156,7 @@ return {
(v3/*: any*/)
],
"kind": "Operation",
"name": "PeopleListPagePaginationQuery",
"name": "PeopleListViewPaginationQuery",
"selections": [
{
"alias": null,
@@ -313,7 +313,7 @@ return {
"orderBy"
],
"handle": "connection",
"key": "PeopleListPage_peoples",
"key": "PeopleListView_peoples",
"kind": "LinkedHandle",
"name": "peoples"
}
@@ -327,16 +327,16 @@ return {
]
},
"params": {
"cacheID": "8a0df2b5a4b762e4b549f018a28add57",
"cacheID": "0bd9bfd41ba132557259776c016df0b9",
"id": null,
"metadata": {},
"name": "PeopleListPagePaginationQuery",
"name": "PeopleListViewPaginationQuery",
"operationKind": "query",
"text": "query PeopleListPagePaginationQuery(\n $after: CursorKey\n $before: CursorKey\n $first: Int\n $last: Int\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PeopleListPage_peoples_pbnwq\n id\n }\n}\n\nfragment PeopleListPage_peoples_pbnwq on Organization {\n id\n peoples(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n}\n"
"text": "query PeopleListViewPaginationQuery(\n $after: CursorKey\n $before: CursorKey\n $first: Int\n $last: Int\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PeopleListView_peoples_pbnwq\n id\n }\n}\n\nfragment PeopleListView_peoples_pbnwq on Organization {\n id\n peoples(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n}\n"
}
};
})();
(node as any).hash = "8346075f50aa8845dded3a6bfa292c2b";
(node as any).hash = "d30fc5e157c1696a257cd337b6f08e1c";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<0e769620c683a087fc071d47649f3ed3>>
* @generated SignedSource<<7d6cb5fe1c907da0abcadd390ca2b492>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,21 +10,21 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PeopleListPageQuery$variables = {
export type PeopleListViewQuery$variables = {
after?: string | null | undefined;
before?: string | null | undefined;
first?: number | null | undefined;
last?: number | null | undefined;
organizationId: string;
};
export type PeopleListPageQuery$data = {
export type PeopleListViewQuery$data = {
readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleListPage_peoples">;
readonly " $fragmentSpreads": FragmentRefs<"PeopleListView_peoples">;
};
};
export type PeopleListPageQuery = {
response: PeopleListPageQuery$data;
variables: PeopleListPageQuery$variables;
export type PeopleListViewQuery = {
response: PeopleListViewQuery$data;
variables: PeopleListViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -119,7 +119,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "PeopleListPageQuery",
"name": "PeopleListViewQuery",
"selections": [
{
"alias": "organization",
@@ -137,7 +137,7 @@ return {
(v9/*: any*/)
],
"kind": "FragmentSpread",
"name": "PeopleListPage_peoples"
"name": "PeopleListView_peoples"
}
],
"storageKey": null
@@ -156,7 +156,7 @@ return {
(v1/*: any*/)
],
"kind": "Operation",
"name": "PeopleListPageQuery",
"name": "PeopleListViewQuery",
"selections": [
{
"alias": "organization",
@@ -313,7 +313,7 @@ return {
"orderBy"
],
"handle": "connection",
"key": "PeopleListPage_peoples",
"key": "PeopleListView_peoples",
"kind": "LinkedHandle",
"name": "peoples"
}
@@ -327,16 +327,16 @@ return {
]
},
"params": {
"cacheID": "b289901944c533975a18564d5af6cd02",
"cacheID": "004c9018c6fc0e77855a01138ccc00d9",
"id": null,
"metadata": {},
"name": "PeopleListPageQuery",
"name": "PeopleListViewQuery",
"operationKind": "query",
"text": "query PeopleListPageQuery(\n $organizationId: ID!\n $first: Int\n $after: CursorKey\n $last: Int\n $before: CursorKey\n) {\n organization: node(id: $organizationId) {\n __typename\n ...PeopleListPage_peoples_pbnwq\n id\n }\n}\n\nfragment PeopleListPage_peoples_pbnwq on Organization {\n id\n peoples(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n}\n"
"text": "query PeopleListViewQuery(\n $organizationId: ID!\n $first: Int\n $after: CursorKey\n $last: Int\n $before: CursorKey\n) {\n organization: node(id: $organizationId) {\n __typename\n ...PeopleListView_peoples_pbnwq\n id\n }\n}\n\nfragment PeopleListView_peoples_pbnwq on Organization {\n id\n peoples(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n}\n"
}
};
})();
(node as any).hash = "0a1918698d276906f5e17baa40f756e3";
(node as any).hash = "560fc78e36c19c5b05a3e67a5e542e3b";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c7666035d738d537e2695a319df441ee>>
* @generated SignedSource<<da4ebef4106cce1470c644c4b8b703a6>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,7 +11,7 @@
import { ReaderFragment } from 'relay-runtime';
export type PeopleKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT";
import { FragmentRefs } from "relay-runtime";
export type PeopleListPage_peoples$data = {
export type PeopleListView_peoples$data = {
readonly id: string;
readonly peoples: {
readonly __id: string;
@@ -33,11 +33,11 @@ export type PeopleListPage_peoples$data = {
readonly startCursor: string | null | undefined;
};
};
readonly " $fragmentType": "PeopleListPage_peoples";
readonly " $fragmentType": "PeopleListView_peoples";
};
export type PeopleListPage_peoples$key = {
readonly " $data"?: PeopleListPage_peoples$data;
readonly " $fragmentSpreads": FragmentRefs<"PeopleListPage_peoples">;
export type PeopleListView_peoples$key = {
readonly " $data"?: PeopleListView_peoples$data;
readonly " $fragmentSpreads": FragmentRefs<"PeopleListView_peoples">;
};
const node: ReaderFragment = (function(){
@@ -99,14 +99,14 @@ return {
"fragmentPathInResult": [
"node"
],
"operation": require('./PeopleListPagePaginationQuery.graphql'),
"operation": require('./PeopleListViewPaginationQuery.graphql'),
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "PeopleListPage_peoples",
"name": "PeopleListView_peoples",
"selections": [
(v1/*: any*/),
{
@@ -123,7 +123,7 @@ return {
],
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "__PeopleListPage_peoples_connection",
"name": "__PeopleListView_peoples_connection",
"plural": false,
"selections": [
{
@@ -257,7 +257,7 @@ return {
]
}
],
"storageKey": "__PeopleListPage_peoples_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
"storageKey": "__PeopleListView_peoples_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
}
],
"type": "Organization",
@@ -265,6 +265,6 @@ return {
};
})();
(node as any).hash = "8346075f50aa8845dded3a6bfa292c2b";
(node as any).hash = "d30fc5e157c1696a257cd337b6f08e1c";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<4ff94d3e8f2e4c6c0d8c3ad43abee2d6>>
* @generated SignedSource<<207a779cf8cbf3e42191be9825d4b653>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,10 +10,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type PeopleKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT";
export type PeopleOverviewPageQuery$variables = {
export type PeopleViewQuery$variables = {
peopleId: string;
};
export type PeopleOverviewPageQuery$data = {
export type PeopleViewQuery$data = {
readonly node: {
readonly additionalEmailAddresses?: ReadonlyArray<string>;
readonly createdAt?: string;
@@ -25,9 +25,9 @@ export type PeopleOverviewPageQuery$data = {
readonly version?: number;
};
};
export type PeopleOverviewPageQuery = {
response: PeopleOverviewPageQuery$data;
variables: PeopleOverviewPageQuery$variables;
export type PeopleViewQuery = {
response: PeopleViewQuery$data;
variables: PeopleViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -106,7 +106,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PeopleOverviewPageQuery",
"name": "PeopleViewQuery",
"selections": [
{
"alias": null,
@@ -142,7 +142,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PeopleOverviewPageQuery",
"name": "PeopleViewQuery",
"selections": [
{
"alias": null,
@@ -180,16 +180,16 @@ return {
]
},
"params": {
"cacheID": "1383ca6e8c8b205082cc68223bec1833",
"cacheID": "c392876240212ba16428ae5edb843d47",
"id": null,
"metadata": {},
"name": "PeopleOverviewPageQuery",
"name": "PeopleViewQuery",
"operationKind": "query",
"text": "query PeopleOverviewPageQuery(\n $peopleId: ID!\n) {\n node(id: $peopleId) {\n __typename\n ... on People {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n version\n }\n id\n }\n}\n"
"text": "query PeopleViewQuery(\n $peopleId: ID!\n) {\n node(id: $peopleId) {\n __typename\n ... on People {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n version\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "fa229186ad9e51f3a60b23fa88a882aa";
(node as any).hash = "4fc97d6cd7fc4590b7be7b5a79ae7ab0";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<4da12585c00c981fc6f2a6f74d5d3ef6>>
* @generated SignedSource<<c1c3f4fa54eb6e43c52b088d2b872fee>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -18,10 +18,10 @@ export type UpdatePeopleInput = {
kind?: PeopleKind | null | undefined;
primaryEmailAddress?: string | null | undefined;
};
export type PeopleOverviewPageUpdatePeopleMutation$variables = {
export type PeopleViewUpdatePeopleMutation$variables = {
input: UpdatePeopleInput;
};
export type PeopleOverviewPageUpdatePeopleMutation$data = {
export type PeopleViewUpdatePeopleMutation$data = {
readonly updatePeople: {
readonly people: {
readonly additionalEmailAddresses: ReadonlyArray<string>;
@@ -34,9 +34,9 @@ export type PeopleOverviewPageUpdatePeopleMutation$data = {
};
};
};
export type PeopleOverviewPageUpdatePeopleMutation = {
response: PeopleOverviewPageUpdatePeopleMutation$data;
variables: PeopleOverviewPageUpdatePeopleMutation$variables;
export type PeopleViewUpdatePeopleMutation = {
response: PeopleViewUpdatePeopleMutation$data;
variables: PeopleViewUpdatePeopleMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -131,7 +131,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PeopleOverviewPageUpdatePeopleMutation",
"name": "PeopleViewUpdatePeopleMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -140,20 +140,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PeopleOverviewPageUpdatePeopleMutation",
"name": "PeopleViewUpdatePeopleMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "6961fac292b72111b2d0a46f078e3efc",
"cacheID": "96bba526a231dd76ef58d32ec01aac3a",
"id": null,
"metadata": {},
"name": "PeopleOverviewPageUpdatePeopleMutation",
"name": "PeopleViewUpdatePeopleMutation",
"operationKind": "mutation",
"text": "mutation PeopleOverviewPageUpdatePeopleMutation(\n $input: UpdatePeopleInput!\n) {\n updatePeople(input: $input) {\n people {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n updatedAt\n version\n }\n }\n}\n"
"text": "mutation PeopleViewUpdatePeopleMutation(\n $input: UpdatePeopleInput!\n) {\n updatePeople(input: $input) {\n people {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n updatedAt\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "01a8fefe43cdc344f9a4dd62a60edc6c";
(node as any).hash = "957952927fbe2337a180599f34ce961c";
export default node;

View File

@@ -1,266 +1,11 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
ConnectionHandler,
graphql,
useMutation,
useQueryLoader,
usePreloadedQuery,
PreloadedQuery,
} from "react-relay";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { Calendar, User } from "lucide-react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import PolicyEditor from "@/components/PolicyEditor";
import PeopleSelector from "@/components/PeopleSelector";
import { Suspense } from "react";
import type { CreatePolicyPageMutation } from "./__generated__/CreatePolicyPageMutation.graphql";
import type { CreatePolicyPageQuery as CreatePolicyPageQueryType } from "./__generated__/CreatePolicyPageQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const CreatePolicyQuery = graphql`
query CreatePolicyPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
...PeopleSelector_organization
}
}
`;
const CreatePolicyView = lazy(() => import("./CreatePolicyView"));
const CreatePolicyMutation = graphql`
mutation CreatePolicyPageMutation(
$input: CreatePolicyInput!
$connections: [ID!]!
) {
createPolicy(input: $input) {
policyEdge @prependEdge(connections: $connections) {
node {
id
name
content
status
reviewDate
owner {
id
fullName
}
}
}
}
}
`;
function CreatePolicyForm({
queryRef,
}: {
queryRef: PreloadedQuery<CreatePolicyPageQueryType>;
}) {
const navigate = useNavigate();
const { organizationId } = useParams();
const data = usePreloadedQuery<CreatePolicyPageQueryType>(
CreatePolicyQuery,
queryRef
);
const [name, setName] = useState("");
const [content, setContent] = useState("");
const [status, setStatus] = useState<"DRAFT" | "ACTIVE">("DRAFT");
const [reviewDate, setReviewDate] = useState("");
const [ownerId, setOwnerId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
console.log(
"CreatePolicyPage state - content:",
content
? content.substring(0, 50) + (content.length > 50 ? "..." : "")
: "empty"
);
const [commitMutation] =
useMutation<CreatePolicyPageMutation>(CreatePolicyMutation);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!ownerId) {
toast({
title: "Error",
description: "Please select an owner for the policy.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
// Convert reviewDate string to ISO format for the API
let reviewDateValue = null;
if (reviewDate) {
reviewDateValue = new Date(reviewDate).toISOString();
}
const input = {
organizationId: organizationId!,
name,
content,
status,
reviewDate: reviewDateValue,
ownerId,
};
commitMutation({
variables: {
input,
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"PolicyListPage_policies"
),
],
},
onCompleted: (response, errors) => {
setIsSubmitting(false);
if (errors) {
console.error("Error creating policy:", errors);
toast({
title: "Error",
description: "Failed to create policy. Please try again.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Policy created successfully!",
});
navigate(
`/organizations/${organizationId}/policies/${response.createPolicy.policyEdge.node.id}`
);
},
onError: (error) => {
setIsSubmitting(false);
console.error("Error creating policy:", error);
toast({
title: "Error",
description: "Failed to create policy. Please try again.",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Policy"
description="Create a new policy for your organization"
>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value: "DRAFT" | "ACTIVE") =>
setStatus(value)
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reviewDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/policies`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Policy"}
</Button>
</div>
</div>
</form>
</PageTemplate>
);
}
export function CreatePolicyPageSkeleton() {
export function CreatePolicyViewSkeleton() {
return (
<PageTemplateSkeleton
title="Create Policy"
@@ -271,24 +16,14 @@ export function CreatePolicyPageSkeleton() {
);
}
export default function CreatePolicyPage() {
const { organizationId } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<CreatePolicyPageQueryType>(CreatePolicyQuery);
useEffect(() => {
if (organizationId) {
loadQuery({ organizationId });
}
}, [organizationId, loadQuery]);
if (!queryRef) {
return <CreatePolicyPageSkeleton />;
}
export function CreatePolicyPage() {
const location = useLocation();
return (
<Suspense fallback={<CreatePolicyPageSkeleton />}>
{<CreatePolicyForm queryRef={queryRef} />}
<Suspense key={location.pathname} fallback={<CreatePolicyViewSkeleton />}>
<ErrorBoundaryWithLocation>
<CreatePolicyView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,284 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
ConnectionHandler,
graphql,
useMutation,
useQueryLoader,
usePreloadedQuery,
PreloadedQuery,
} from "react-relay";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { Calendar, User } from "lucide-react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import PolicyEditor from "@/components/PolicyEditor";
import PeopleSelector from "@/components/PeopleSelector";
import { Suspense } from "react";
import type { CreatePolicyViewMutation } from "./__generated__/CreatePolicyViewMutation.graphql";
import type { CreatePolicyViewQuery as CreatePolicyViewQueryType } from "./__generated__/CreatePolicyViewQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { CreatePolicyViewSkeleton } from "./CreatePolicyPage";
const CreatePolicyQuery = graphql`
query CreatePolicyViewQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
...PeopleSelector_organization
}
}
`;
const CreatePolicyMutation = graphql`
mutation CreatePolicyViewMutation(
$input: CreatePolicyInput!
$connections: [ID!]!
) {
createPolicy(input: $input) {
policyEdge @prependEdge(connections: $connections) {
node {
id
name
content
status
reviewDate
owner {
id
fullName
}
}
}
}
}
`;
function CreatePolicyForm({
queryRef,
}: {
queryRef: PreloadedQuery<CreatePolicyViewQueryType>;
}) {
const navigate = useNavigate();
const { organizationId } = useParams();
const data = usePreloadedQuery<CreatePolicyViewQueryType>(
CreatePolicyQuery,
queryRef
);
const [name, setName] = useState("");
const [content, setContent] = useState("");
const [status, setStatus] = useState<"DRAFT" | "ACTIVE">("DRAFT");
const [reviewDate, setReviewDate] = useState("");
const [ownerId, setOwnerId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
console.log(
"CreatePolicyView state - content:",
content
? content.substring(0, 50) + (content.length > 50 ? "..." : "")
: "empty"
);
const [commitMutation] =
useMutation<CreatePolicyViewMutation>(CreatePolicyMutation);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!ownerId) {
toast({
title: "Error",
description: "Please select an owner for the policy.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
// Convert reviewDate string to ISO format for the API
let reviewDateValue = null;
if (reviewDate) {
reviewDateValue = new Date(reviewDate).toISOString();
}
const input = {
organizationId: organizationId!,
name,
content,
status,
reviewDate: reviewDateValue,
ownerId,
};
commitMutation({
variables: {
input,
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"PolicyListPage_policies"
),
],
},
onCompleted: (response, errors) => {
setIsSubmitting(false);
if (errors) {
console.error("Error creating policy:", errors);
toast({
title: "Error",
description: "Failed to create policy. Please try again.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Policy created successfully!",
});
navigate(
`/organizations/${organizationId}/policies/${response.createPolicy.policyEdge.node.id}`
);
},
onError: (error) => {
setIsSubmitting(false);
console.error("Error creating policy:", error);
toast({
title: "Error",
description: "Failed to create policy. Please try again.",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Policy"
description="Create a new policy for your organization"
>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value: "DRAFT" | "ACTIVE") =>
setStatus(value)
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reviewDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/policies`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Policy"}
</Button>
</div>
</div>
</form>
</PageTemplate>
);
}
export default function CreatePolicyView() {
const { organizationId } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<CreatePolicyViewQueryType>(CreatePolicyQuery);
useEffect(() => {
if (organizationId) {
loadQuery({ organizationId });
}
}, [organizationId, loadQuery]);
if (!queryRef) {
return <CreatePolicyViewSkeleton />;
}
return (
<Suspense fallback={<CreatePolicyViewSkeleton />}>
{<CreatePolicyForm queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -1,353 +1,12 @@
import { Suspense, useEffect, useState } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
FileText,
Search,
Clock,
Filter,
ArrowUpDown,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import type { PolicyListPageQuery as PolicyListPageQueryType } from "./__generated__/PolicyListPageQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const PolicyListPageQuery = graphql`
query PolicyListPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
policies(first: 25) @connection(key: "PolicyListPage_policies") {
edges {
node {
id
name
content
createdAt
updatedAt
status
}
}
}
}
}
}
`;
const PolicyListView = lazy(() => import("./PolicyListView"));
function PolicyCard({
title,
content,
status,
updatedAt,
}: {
title: string;
content?: string;
status?: string;
updatedAt: string;
}) {
const formattedUpdatedAt = new Date(updatedAt);
// Extract a short description from the content and strip HTML tags
const stripHtmlTags = (html: string) => {
// First remove HTML tags
const withoutTags = html.replace(/<[^>]*>/g, "");
// Then decode HTML entities
const decoded = withoutTags
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/g, " ");
// Remove markdown headers
return decoded.replace(/#.*?\n/, "").trim();
};
const description = content
? stripHtmlTags(content).substring(0, 120) +
(content.length > 120 ? "..." : "")
: "No description available";
return (
<Card className="relative overflow-hidden border bg-card transition-all hover:shadow-md h-full flex flex-col">
<CardContent className="p-6 flex-grow">
<div className="flex flex-col h-full">
<div className="flex justify-between items-start mb-3">
<h3 className="font-semibold text-xl">{title}</h3>
{status && (
<Badge
className={`${
status === "ACTIVE"
? "bg-green-100 text-green-700 hover:bg-green-200"
: status === "DRAFT"
? "bg-yellow-100 text-yellow-700 hover:bg-yellow-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{status === "ACTIVE"
? "Security"
: status === "DRAFT"
? "Draft"
: status}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm line-clamp-3 mb-4">
{description}
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-auto">
<Clock className="h-4 w-4" />
<span>
Last updated: {format(formattedUpdatedAt, "yyyy-MM-dd")}
</span>
</div>
</div>
</CardContent>
<CardFooter className="p-0 border-t">
<div className="w-full grid grid-cols-2">
<Button variant="ghost" className="rounded-none h-12 border-r">
<FileText className="h-5 w-5 mr-2" />
View Policy
</Button>
<Button variant="ghost" className="rounded-none h-12">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download
</Button>
</div>
</CardFooter>
</Card>
);
}
function PolicyListPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<PolicyListPageQueryType>;
}) {
const data = usePreloadedQuery<PolicyListPageQueryType>(
PolicyListPageQuery,
queryRef
);
const { organizationId } = useParams();
const policies =
data.organization.policies?.edges.map((edge) => edge?.node) ?? [];
// State for search, filtering and sorting
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [sortBy, setSortBy] = useState("name-asc");
// Filter and sort policies
const filteredPolicies = policies
.filter((policy) => {
// Filter by search query
const matchesSearch = policy.name
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Filter by status
const matchesStatus =
statusFilter === "ALL" || policy.status === statusFilter;
return matchesSearch && matchesStatus;
})
.sort((a, b) => {
// Sort policies
switch (sortBy) {
case "name-asc":
return a.name.localeCompare(b.name);
case "name-desc":
return b.name.localeCompare(a.name);
case "updated-desc":
return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
case "updated-asc":
return (
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
);
case "created-desc":
return (
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
case "created-asc":
return (
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
default:
return 0;
}
});
return (
<PageTemplate
title="Policies"
description="Manage your organization's policies"
actions={
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
}
>
{/* Search and filter controls */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search policies..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<SelectValue placeholder="Filter by status" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy("name-asc")}>
Name (A-Z)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("name-desc")}>
Name (Z-A)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-desc")}>
Recently Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-asc")}>
Oldest Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-desc")}>
Recently Created
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-asc")}>
Oldest Created
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Results summary */}
<div className="mb-4 text-sm text-muted-foreground">
Showing {filteredPolicies.length} of {policies.length} policies
</div>
{/* Policy grid */}
<div className="space-y-6">
{filteredPolicies.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPolicies.map((policy) => (
<Link
key={policy.id}
to={`/organizations/${organizationId}/policies/${policy.id}`}
className="group"
>
<PolicyCard
title={policy.name}
content={policy.content}
status={policy.status}
updatedAt={policy.updatedAt}
/>
</Link>
))}
</div>
) : (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<FileText className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
<h3 className="text-lg font-medium">No policies found</h3>
<p className="text-muted-foreground mb-4">
{searchQuery || statusFilter !== "ALL"
? "Try adjusting your search or filters"
: "Create your first policy to get started"}
</p>
{searchQuery || statusFilter !== "ALL" ? (
<Button
variant="outline"
onClick={() => {
setSearchQuery("");
setStatusFilter("ALL");
}}
>
Clear filters
</Button>
) : (
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
)}
</div>
)}
</div>
</PageTemplate>
);
}
export function PolicyListPageSkeleton() {
export function PolicyListViewSkeleton() {
return (
<PageTemplateSkeleton
title="Policies"
@@ -398,23 +57,14 @@ export function PolicyListPageSkeleton() {
);
}
export default function PolicyListPage() {
const [queryRef, loadQuery] =
useQueryLoader<PolicyListPageQueryType>(PolicyListPageQuery);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PolicyListPageSkeleton />;
}
export function PolicyListPage() {
const location = useLocation();
return (
<Suspense fallback={<PolicyListPageSkeleton />}>
{<PolicyListPageContent queryRef={queryRef} />}
<Suspense key={location.pathname} fallback={<PolicyListViewSkeleton />}>
<ErrorBoundaryWithLocation>
<PolicyListView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,370 @@
import { Suspense, useEffect, useState } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
FileText,
Search,
Clock,
Filter,
ArrowUpDown,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import type { PolicyListViewQuery as PolicyListViewQueryType } from "./__generated__/PolicyListViewQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { PolicyListViewSkeleton } from "./PolicyListPage";
const PolicyListViewQuery = graphql`
query PolicyListViewQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
policies(first: 25) @connection(key: "PolicyListView_policies") {
edges {
node {
id
name
content
createdAt
updatedAt
status
}
}
}
}
}
}
`;
function PolicyCard({
title,
content,
status,
updatedAt,
}: {
title: string;
content?: string;
status?: string;
updatedAt: string;
}) {
const formattedUpdatedAt = new Date(updatedAt);
// Extract a short description from the content and strip HTML tags
const stripHtmlTags = (html: string) => {
// First remove HTML tags
const withoutTags = html.replace(/<[^>]*>/g, "");
// Then decode HTML entities
const decoded = withoutTags
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/g, " ");
// Remove markdown headers
return decoded.replace(/#.*?\n/, "").trim();
};
const description = content
? stripHtmlTags(content).substring(0, 120) +
(content.length > 120 ? "..." : "")
: "No description available";
return (
<Card className="relative overflow-hidden border bg-card transition-all hover:shadow-md h-full flex flex-col">
<CardContent className="p-6 flex-grow">
<div className="flex flex-col h-full">
<div className="flex justify-between items-start mb-3">
<h3 className="font-semibold text-xl">{title}</h3>
{status && (
<Badge
className={`${
status === "ACTIVE"
? "bg-green-100 text-green-700 hover:bg-green-200"
: status === "DRAFT"
? "bg-yellow-100 text-yellow-700 hover:bg-yellow-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{status === "ACTIVE"
? "Security"
: status === "DRAFT"
? "Draft"
: status}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm line-clamp-3 mb-4">
{description}
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-auto">
<Clock className="h-4 w-4" />
<span>
Last updated: {format(formattedUpdatedAt, "yyyy-MM-dd")}
</span>
</div>
</div>
</CardContent>
<CardFooter className="p-0 border-t">
<div className="w-full grid grid-cols-2">
<Button variant="ghost" className="rounded-none h-12 border-r">
<FileText className="h-5 w-5 mr-2" />
View Policy
</Button>
<Button variant="ghost" className="rounded-none h-12">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download
</Button>
</div>
</CardFooter>
</Card>
);
}
function PolicyListViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<PolicyListViewQueryType>;
}) {
const data = usePreloadedQuery<PolicyListViewQueryType>(
PolicyListViewQuery,
queryRef
);
const { organizationId } = useParams();
const policies =
data.organization.policies?.edges.map((edge) => edge?.node) ?? [];
// State for search, filtering and sorting
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [sortBy, setSortBy] = useState("name-asc");
// Filter and sort policies
const filteredPolicies = policies
.filter((policy) => {
// Filter by search query
const matchesSearch = policy.name
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Filter by status
const matchesStatus =
statusFilter === "ALL" || policy.status === statusFilter;
return matchesSearch && matchesStatus;
})
.sort((a, b) => {
// Sort policies
switch (sortBy) {
case "name-asc":
return a.name.localeCompare(b.name);
case "name-desc":
return b.name.localeCompare(a.name);
case "updated-desc":
return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
case "updated-asc":
return (
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
);
case "created-desc":
return (
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
case "created-asc":
return (
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
default:
return 0;
}
});
return (
<PageTemplate
title="Policies"
description="Manage your organization's policies"
actions={
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
}
>
{/* Search and filter controls */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search policies..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<SelectValue placeholder="Filter by status" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy("name-asc")}>
Name (A-Z)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("name-desc")}>
Name (Z-A)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-desc")}>
Recently Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-asc")}>
Oldest Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-desc")}>
Recently Created
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-asc")}>
Oldest Created
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Results summary */}
<div className="mb-4 text-sm text-muted-foreground">
Showing {filteredPolicies.length} of {policies.length} policies
</div>
{/* Policy grid */}
<div className="space-y-6">
{filteredPolicies.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPolicies.map((policy) => (
<Link
key={policy.id}
to={`/organizations/${organizationId}/policies/${policy.id}`}
className="group"
>
<PolicyCard
title={policy.name}
content={policy.content}
status={policy.status}
updatedAt={policy.updatedAt}
/>
</Link>
))}
</div>
) : (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<FileText className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
<h3 className="text-lg font-medium">No policies found</h3>
<p className="text-muted-foreground mb-4">
{searchQuery || statusFilter !== "ALL"
? "Try adjusting your search or filters"
: "Create your first policy to get started"}
</p>
{searchQuery || statusFilter !== "ALL" ? (
<Button
variant="outline"
onClick={() => {
setSearchQuery("");
setStatusFilter("ALL");
}}
>
Clear filters
</Button>
) : (
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
)}
</div>
)}
</div>
</PageTemplate>
);
}
export default function PolicyListView() {
const [queryRef, loadQuery] =
useQueryLoader<PolicyListViewQueryType>(PolicyListViewQuery);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PolicyListViewSkeleton />;
}
return (
<Suspense fallback={<PolicyListViewSkeleton />}>
{<PolicyListViewContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -0,0 +1,120 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Card, CardContent } from "@/components/ui/card";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const PolicyView = lazy(() => import("./PolicyView"));
export function PolicyViewSkeleton() {
return (
<PageTemplateSkeleton
withDescription
actions={
<div className="flex gap-2 w-1/3">
<div className="bg-muted animate-pulse h-8 w-1/2 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/2 rounded-lg" />
</div>
}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="border shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-slate-100/50 h-10 w-10 animate-pulse" />
<div className="flex gap-2">
<div className="h-6 w-16 bg-muted animate-pulse rounded-md" />
<div className="h-6 w-20 bg-muted animate-pulse rounded-md" />
<div className="h-6 w-16 bg-muted animate-pulse rounded-md" />
</div>
<div className="ml-auto">
<div className="h-9 w-9 bg-muted animate-pulse rounded-full" />
</div>
</div>
<div className="h-8 w-3/4 bg-muted animate-pulse rounded mb-3" />
<div className="space-y-2 mb-6">
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-2/3 bg-muted animate-pulse rounded" />
</div>
<div className="flex border-b mb-6">
<div className="px-4 py-2 h-8 w-28 bg-muted animate-pulse rounded" />
<div className="px-4 py-2 h-8 w-28 bg-muted animate-pulse rounded opacity-50" />
<div className="px-4 py-2 h-8 w-28 bg-muted animate-pulse rounded opacity-50" />
</div>
<div className="space-y-4">
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-3/4 bg-muted animate-pulse rounded" />
</div>
</CardContent>
</Card>
</div>
<div className="lg:col-span-1">
<Card className="border shadow-sm mb-6">
<CardContent className="p-6">
<div className="h-6 w-32 bg-muted animate-pulse rounded mb-6" />
<div className="space-y-6">
<div>
<div className="h-4 w-28 bg-muted animate-pulse rounded mb-1" />
<div className="h-5 w-24 bg-muted animate-pulse rounded" />
</div>
<div>
<div className="h-4 w-28 bg-muted animate-pulse rounded mb-1" />
<div className="h-5 w-24 bg-muted animate-pulse rounded" />
</div>
<div>
<div className="h-4 w-28 bg-muted animate-pulse rounded mb-1" />
<div className="h-5 w-36 bg-muted animate-pulse rounded" />
</div>
<hr className="my-4" />
<div>
<div className="h-5 w-32 bg-muted animate-pulse rounded mb-3" />
<div className="space-y-2">
<div className="h-4 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-48 bg-muted animate-pulse rounded" />
</div>
</div>
</div>
</CardContent>
</Card>
<div className="h-10 w-full bg-muted animate-pulse rounded mb-6" />
<Card className="border shadow-sm bg-red-50/30">
<CardContent className="p-6">
<div className="h-5 w-28 bg-muted animate-pulse rounded mb-3" />
<div className="h-16 w-full bg-muted animate-pulse rounded mb-4" />
<div className="h-10 w-full bg-muted animate-pulse rounded" />
</CardContent>
</Card>
</div>
</div>
</PageTemplateSkeleton>
);
}
export function PolicyPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<PolicyViewSkeleton />}>
<ErrorBoundaryWithLocation>
<PolicyView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -13,13 +13,14 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { PolicyOverviewPageQuery as PolicyOverviewPageQueryType } from "./__generated__/PolicyOverviewPageQuery.graphql";
import type { PolicyOverviewPageDeleteMutation } from "./__generated__/PolicyOverviewPageDeleteMutation.graphql";
import type { PolicyViewQuery as PolicyViewQueryType } from "./__generated__/PolicyViewQuery.graphql";
import type { PolicyViewDeleteMutation } from "./__generated__/PolicyViewDeleteMutation.graphql";
import { useToast } from "@/hooks/use-toast";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplate } from "@/components/PageTemplate";
import { PolicyViewSkeleton } from "./PolicyPage";
const PolicyOverviewPageQuery = graphql`
query PolicyOverviewPageQuery($policyId: ID!) {
const PolicyViewQuery = graphql`
query PolicyViewQuery($policyId: ID!) {
node(id: $policyId) {
id
... on Policy {
@@ -40,7 +41,7 @@ const PolicyOverviewPageQuery = graphql`
`;
const DeletePolicyMutation = graphql`
mutation PolicyOverviewPageDeleteMutation(
mutation PolicyViewDeleteMutation(
$input: DeletePolicyInput!
$connections: [ID!]!
) {
@@ -50,12 +51,12 @@ const DeletePolicyMutation = graphql`
}
`;
function PolicyOverviewPageContent({
function PolicyViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<PolicyOverviewPageQueryType>;
queryRef: PreloadedQuery<PolicyViewQueryType>;
}) {
const data = usePreloadedQuery(PolicyOverviewPageQuery, queryRef);
const data = usePreloadedQuery(PolicyViewQuery, queryRef);
const policy = data.node;
const { organizationId } = useParams();
const navigate = useNavigate();
@@ -64,7 +65,7 @@ function PolicyOverviewPageContent({
const { toast } = useToast();
const [commitDeleteMutation] =
useMutation<PolicyOverviewPageDeleteMutation>(DeletePolicyMutation);
useMutation<PolicyViewDeleteMutation>(DeletePolicyMutation);
const handleDeletePolicy = useCallback(() => {
if (
@@ -402,111 +403,9 @@ function PolicyOverviewPageContent({
);
}
export function PolicyOverviewPageSkeleton() {
return (
<PageTemplateSkeleton
withDescription
actions={
<div className="flex gap-2 w-1/3">
<div className="bg-muted animate-pulse h-8 w-1/2 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/2 rounded-lg" />
</div>
}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="border shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-slate-100/50 h-10 w-10 animate-pulse" />
<div className="flex gap-2">
<div className="h-6 w-16 bg-muted animate-pulse rounded-md" />
<div className="h-6 w-20 bg-muted animate-pulse rounded-md" />
<div className="h-6 w-16 bg-muted animate-pulse rounded-md" />
</div>
<div className="ml-auto">
<div className="h-9 w-9 bg-muted animate-pulse rounded-full" />
</div>
</div>
<div className="h-8 w-3/4 bg-muted animate-pulse rounded mb-3" />
<div className="space-y-2 mb-6">
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-2/3 bg-muted animate-pulse rounded" />
</div>
<div className="flex border-b mb-6">
<div className="px-4 py-2 h-8 w-28 bg-muted animate-pulse rounded" />
<div className="px-4 py-2 h-8 w-28 bg-muted animate-pulse rounded opacity-50" />
<div className="px-4 py-2 h-8 w-28 bg-muted animate-pulse rounded opacity-50" />
</div>
<div className="space-y-4">
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-full bg-muted animate-pulse rounded" />
<div className="h-4 w-3/4 bg-muted animate-pulse rounded" />
</div>
</CardContent>
</Card>
</div>
<div className="lg:col-span-1">
<Card className="border shadow-sm mb-6">
<CardContent className="p-6">
<div className="h-6 w-32 bg-muted animate-pulse rounded mb-6" />
<div className="space-y-6">
<div>
<div className="h-4 w-28 bg-muted animate-pulse rounded mb-1" />
<div className="h-5 w-24 bg-muted animate-pulse rounded" />
</div>
<div>
<div className="h-4 w-28 bg-muted animate-pulse rounded mb-1" />
<div className="h-5 w-24 bg-muted animate-pulse rounded" />
</div>
<div>
<div className="h-4 w-28 bg-muted animate-pulse rounded mb-1" />
<div className="h-5 w-36 bg-muted animate-pulse rounded" />
</div>
<hr className="my-4" />
<div>
<div className="h-5 w-32 bg-muted animate-pulse rounded mb-3" />
<div className="space-y-2">
<div className="h-4 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-48 bg-muted animate-pulse rounded" />
</div>
</div>
</div>
</CardContent>
</Card>
<div className="h-10 w-full bg-muted animate-pulse rounded mb-6" />
<Card className="border shadow-sm bg-red-50/30">
<CardContent className="p-6">
<div className="h-5 w-28 bg-muted animate-pulse rounded mb-3" />
<div className="h-16 w-full bg-muted animate-pulse rounded mb-4" />
<div className="h-10 w-full bg-muted animate-pulse rounded" />
</CardContent>
</Card>
</div>
</div>
</PageTemplateSkeleton>
);
}
export default function PolicyOverviewPage() {
const [queryRef, loadQuery] = useQueryLoader<PolicyOverviewPageQueryType>(
PolicyOverviewPageQuery
);
export default function PolicyView() {
const [queryRef, loadQuery] =
useQueryLoader<PolicyViewQueryType>(PolicyViewQuery);
const { policyId } = useParams();
@@ -515,12 +414,12 @@ export default function PolicyOverviewPage() {
}, [loadQuery, policyId]);
if (!queryRef) {
return <PolicyOverviewPageSkeleton />;
return <PolicyViewSkeleton />;
}
return (
<Suspense fallback={<PolicyOverviewPageSkeleton />}>
{queryRef && <PolicyOverviewPageContent queryRef={queryRef} />}
<Suspense fallback={<PolicyViewSkeleton />}>
{queryRef && <PolicyViewContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -1,257 +1,11 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
useQueryLoader,
PreloadedQuery,
} from "react-relay";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/hooks/use-toast";
import { Calendar, User } from "lucide-react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Suspense } from "react";
import PolicyEditor from "@/components/PolicyEditor";
import PeopleSelector from "@/components/PeopleSelector";
import type { UpdatePolicyPageQuery as UpdatePolicyPageQueryType } from "./__generated__/UpdatePolicyPageQuery.graphql";
import type { UpdatePolicyPageMutation as UpdatePolicyPageMutationType } from "./__generated__/UpdatePolicyPageMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const UpdatePolicyPageQuery = graphql`
query UpdatePolicyPageQuery($policyId: ID!, $organizationId: ID!) {
policy: node(id: $policyId) {
id
... on Policy {
name
content
status
version
reviewDate
owner {
id
fullName
}
}
}
organization: node(id: $organizationId) {
...PeopleSelector_organization
}
}
`;
const UpdatePolicyView = lazy(() => import("./UpdatePolicyView"));
const UpdatePolicyMutation = graphql`
mutation UpdatePolicyPageMutation($input: UpdatePolicyInput!) {
updatePolicy(input: $input) {
policy {
id
name
content
status
version
reviewDate
owner {
id
fullName
}
}
}
}
`;
function UpdatePolicyPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<UpdatePolicyPageQueryType>;
}) {
const navigate = useNavigate();
const { organizationId, policyId } = useParams();
const data = usePreloadedQuery<UpdatePolicyPageQueryType>(
UpdatePolicyPageQuery,
queryRef
);
console.log("UpdatePolicyPage data:", data.policy);
const [name, setName] = useState(data.policy.name);
const [content, setContent] = useState(data.policy.content || "");
const [status, setStatus] = useState(data.policy.status);
const [reviewDate, setReviewDate] = useState(data.policy.reviewDate || "");
const [ownerId, setOwnerId] = useState<string | null>(
data.policy.owner?.id || null
);
const [isSubmitting, setIsSubmitting] = useState(false);
console.log(
"UpdatePolicyPage state - content:",
content
? content.substring(0, 50) + (content.length > 50 ? "..." : "")
: "empty"
);
const [commitMutation] =
useMutation<UpdatePolicyPageMutationType>(UpdatePolicyMutation);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
// Convert reviewDate string to ISO format for the API
let reviewDateValue = null;
if (reviewDate) {
reviewDateValue = new Date(reviewDate).toISOString();
}
commitMutation({
variables: {
input: {
id: data.policy.id,
name,
content,
status,
reviewDate: reviewDateValue,
ownerId,
expectedVersion: data.policy.version!,
},
},
onCompleted: (response, errors) => {
setIsSubmitting(false);
if (errors) {
console.error("Error updating policy:", errors);
toast({
title: "Error",
description: "Failed to update policy. Please try again.",
});
return;
}
toast({
title: "Success",
description: "Policy updated successfully.",
});
navigate(`/organizations/${organizationId}/policies/${policyId}`);
},
onError: (error) => {
setIsSubmitting(false);
console.error("Error updating policy:", error);
toast({
title: "Error",
description: "Failed to update policy. Please try again.",
});
},
});
};
return (
<PageTemplate title="Update Policy" description="Update an existing policy">
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value) =>
setStatus(value as "DRAFT" | "ACTIVE")
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reviewDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/policies/${policyId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Policy"}
</Button>
</div>
</div>
</form>
</PageTemplate>
);
}
export function UpdatePolicyPageSkeleton() {
export function UpdatePolicyViewSkeleton() {
return (
<PageTemplateSkeleton
title="Update Policy"
@@ -271,25 +25,14 @@ export function UpdatePolicyPageSkeleton() {
);
}
export default function UpdatePolicyPage() {
const { organizationId, policyId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<UpdatePolicyPageQueryType>(
UpdatePolicyPageQuery
);
useEffect(() => {
if (organizationId && policyId) {
loadQuery({ organizationId, policyId });
}
}, [organizationId, policyId, loadQuery]);
if (!queryRef) {
return <UpdatePolicyPageSkeleton />;
}
export function UpdatePolicyPage() {
const location = useLocation();
return (
<Suspense fallback={<UpdatePolicyPageSkeleton />}>
{queryRef && <UpdatePolicyPageContent queryRef={queryRef} />}
<Suspense key={location.pathname} fallback={<UpdatePolicyViewSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdatePolicyView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,276 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
useQueryLoader,
PreloadedQuery,
} from "react-relay";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/hooks/use-toast";
import { Calendar, User } from "lucide-react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Suspense } from "react";
import PolicyEditor from "@/components/PolicyEditor";
import PeopleSelector from "@/components/PeopleSelector";
import type { UpdatePolicyViewQuery as UpdatePolicyViewQueryType } from "./__generated__/UpdatePolicyViewQuery.graphql";
import type { UpdatePolicyViewMutation as UpdatePolicyViewMutationType } from "./__generated__/UpdatePolicyViewMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { UpdatePolicyViewSkeleton } from "./UpdatePolicyPage";
const UpdatePolicyViewQuery = graphql`
query UpdatePolicyViewQuery($policyId: ID!, $organizationId: ID!) {
policy: node(id: $policyId) {
id
... on Policy {
name
content
status
version
reviewDate
owner {
id
fullName
}
}
}
organization: node(id: $organizationId) {
...PeopleSelector_organization
}
}
`;
const UpdatePolicyMutation = graphql`
mutation UpdatePolicyViewMutation($input: UpdatePolicyInput!) {
updatePolicy(input: $input) {
policy {
id
name
content
status
version
reviewDate
owner {
id
fullName
}
}
}
}
`;
function UpdatePolicyViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<UpdatePolicyViewQueryType>;
}) {
const navigate = useNavigate();
const { organizationId, policyId } = useParams();
const data = usePreloadedQuery<UpdatePolicyViewQueryType>(
UpdatePolicyViewQuery,
queryRef
);
console.log("UpdatePolicyView data:", data.policy);
const [name, setName] = useState(data.policy.name);
const [content, setContent] = useState(data.policy.content || "");
const [status, setStatus] = useState(data.policy.status);
const [reviewDate, setReviewDate] = useState(data.policy.reviewDate || "");
const [ownerId, setOwnerId] = useState<string | null>(
data.policy.owner?.id || null
);
const [isSubmitting, setIsSubmitting] = useState(false);
console.log(
"UpdatePolicyView state - content:",
content
? content.substring(0, 50) + (content.length > 50 ? "..." : "")
: "empty"
);
const [commitMutation] =
useMutation<UpdatePolicyViewMutationType>(UpdatePolicyMutation);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
// Convert reviewDate string to ISO format for the API
let reviewDateValue = null;
if (reviewDate) {
reviewDateValue = new Date(reviewDate).toISOString();
}
commitMutation({
variables: {
input: {
id: data.policy.id,
name,
content,
status,
reviewDate: reviewDateValue,
ownerId,
expectedVersion: data.policy.version!,
},
},
onCompleted: (response, errors) => {
setIsSubmitting(false);
if (errors) {
console.error("Error updating policy:", errors);
toast({
title: "Error",
description: "Failed to update policy. Please try again.",
});
return;
}
toast({
title: "Success",
description: "Policy updated successfully.",
});
navigate(`/organizations/${organizationId}/policies/${policyId}`);
},
onError: (error) => {
setIsSubmitting(false);
console.error("Error updating policy:", error);
toast({
title: "Error",
description: "Failed to update policy. Please try again.",
});
},
});
};
return (
<PageTemplate title="Update Policy" description="Update an existing policy">
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value) =>
setStatus(value as "DRAFT" | "ACTIVE")
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reviewDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/policies/${policyId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Policy"}
</Button>
</div>
</div>
</form>
</PageTemplate>
);
}
export default function UpdatePolicyView() {
const { organizationId, policyId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<UpdatePolicyViewQueryType>(
UpdatePolicyViewQuery
);
useEffect(() => {
if (organizationId && policyId) {
loadQuery({ organizationId, policyId });
}
}, [organizationId, policyId, loadQuery]);
if (!queryRef) {
return <UpdatePolicyViewSkeleton />;
}
return (
<Suspense fallback={<UpdatePolicyViewSkeleton />}>
{queryRef && <UpdatePolicyViewContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<29caed5a50be912ff48f9c131ce4045b>>
* @generated SignedSource<<6100eacd24cc3b7e7fe5dc4539323e75>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -18,11 +18,11 @@ export type CreatePolicyInput = {
reviewDate?: string | null | undefined;
status: PolicyStatus;
};
export type CreatePolicyPageMutation$variables = {
export type CreatePolicyViewMutation$variables = {
connections: ReadonlyArray<string>;
input: CreatePolicyInput;
};
export type CreatePolicyPageMutation$data = {
export type CreatePolicyViewMutation$data = {
readonly createPolicy: {
readonly policyEdge: {
readonly node: {
@@ -39,9 +39,9 @@ export type CreatePolicyPageMutation$data = {
};
};
};
export type CreatePolicyPageMutation = {
response: CreatePolicyPageMutation$data;
variables: CreatePolicyPageMutation$variables;
export type CreatePolicyViewMutation = {
response: CreatePolicyViewMutation$data;
variables: CreatePolicyViewMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -147,7 +147,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "CreatePolicyPageMutation",
"name": "CreatePolicyViewMutation",
"selections": [
{
"alias": null,
@@ -172,7 +172,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreatePolicyPageMutation",
"name": "CreatePolicyViewMutation",
"selections": [
{
"alias": null,
@@ -205,16 +205,16 @@ return {
]
},
"params": {
"cacheID": "cf17928879d0f2b2e68367ed2c132d0a",
"cacheID": "732425c047d258a0c592d60073c4fcbd",
"id": null,
"metadata": {},
"name": "CreatePolicyPageMutation",
"name": "CreatePolicyViewMutation",
"operationKind": "mutation",
"text": "mutation CreatePolicyPageMutation(\n $input: CreatePolicyInput!\n) {\n createPolicy(input: $input) {\n policyEdge {\n node {\n id\n name\n content\n status\n reviewDate\n owner {\n id\n fullName\n }\n }\n }\n }\n}\n"
"text": "mutation CreatePolicyViewMutation(\n $input: CreatePolicyInput!\n) {\n createPolicy(input: $input) {\n policyEdge {\n node {\n id\n name\n content\n status\n reviewDate\n owner {\n id\n fullName\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "1ddb9b7010ecf1fe55e2b56aaa034295";
(node as any).hash = "af0c4091836cf3c833749da8495e378f";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<21831aed35a902922b54a96fcb5c8272>>
* @generated SignedSource<<0d6c35628ee18fe62e21ea579a02d118>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,17 +10,17 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreatePolicyPageQuery$variables = {
export type CreatePolicyViewQuery$variables = {
organizationId: string;
};
export type CreatePolicyPageQuery$data = {
export type CreatePolicyViewQuery$data = {
readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleSelector_organization">;
};
};
export type CreatePolicyPageQuery = {
response: CreatePolicyPageQuery$data;
variables: CreatePolicyPageQuery$variables;
export type CreatePolicyViewQuery = {
response: CreatePolicyViewQuery$data;
variables: CreatePolicyViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -72,7 +72,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "CreatePolicyPageQuery",
"name": "CreatePolicyViewQuery",
"selections": [
{
"alias": "organization",
@@ -98,7 +98,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "CreatePolicyPageQuery",
"name": "CreatePolicyViewQuery",
"selections": [
{
"alias": "organization",
@@ -215,16 +215,16 @@ return {
]
},
"params": {
"cacheID": "e9d10cdbee082ff5e5f311b547bbc73d",
"cacheID": "85c335b0eeb8dc59c5255083f3c3c7e8",
"id": null,
"metadata": {},
"name": "CreatePolicyPageQuery",
"name": "CreatePolicyViewQuery",
"operationKind": "query",
"text": "query CreatePolicyPageQuery(\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 CreatePolicyViewQuery(\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"
}
};
})();
(node as any).hash = "9ab69c4bfcba5c896760f297ab38ff51";
(node as any).hash = "9184af33ef358fb3c201d41469d49cc8";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<a49c4b1ca92e109b1091a1431fe0c871>>
* @generated SignedSource<<605e2b8b9bc6fdd85a121a1f1904cc00>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,10 +10,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type PolicyStatus = "ACTIVE" | "DRAFT";
export type PolicyListPageQuery$variables = {
export type PolicyListViewQuery$variables = {
organizationId: string;
};
export type PolicyListPageQuery$data = {
export type PolicyListViewQuery$data = {
readonly organization: {
readonly policies?: {
readonly edges: ReadonlyArray<{
@@ -29,9 +29,9 @@ export type PolicyListPageQuery$data = {
};
};
};
export type PolicyListPageQuery = {
response: PolicyListPageQuery$data;
variables: PolicyListPageQuery$variables;
export type PolicyListViewQuery = {
response: PolicyListViewQuery$data;
variables: PolicyListViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -168,7 +168,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PolicyListPageQuery",
"name": "PolicyListViewQuery",
"selections": [
{
"alias": "organization",
@@ -186,7 +186,7 @@ return {
"args": null,
"concreteType": "PolicyConnection",
"kind": "LinkedField",
"name": "__PolicyListPage_policies_connection",
"name": "__PolicyListView_policies_connection",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
@@ -206,7 +206,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PolicyListPageQuery",
"name": "PolicyListViewQuery",
"selections": [
{
"alias": "organization",
@@ -235,7 +235,7 @@ return {
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "PolicyListPage_policies",
"key": "PolicyListView_policies",
"kind": "LinkedHandle",
"name": "policies"
}
@@ -250,7 +250,7 @@ return {
]
},
"params": {
"cacheID": "5aa6e466b6daa957aa9c582530e385ec",
"cacheID": "361b81169a803b656d673b7b8d1c5583",
"id": null,
"metadata": {
"connection": [
@@ -265,13 +265,13 @@ return {
}
]
},
"name": "PolicyListPageQuery",
"name": "PolicyListViewQuery",
"operationKind": "query",
"text": "query PolicyListPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n policies(first: 25) {\n edges {\n node {\n id\n name\n content\n createdAt\n updatedAt\n status\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
"text": "query PolicyListViewQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n policies(first: 25) {\n edges {\n node {\n id\n name\n content\n createdAt\n updatedAt\n status\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "853e3413fd1b3f8781b11a1b7679c08f";
(node as any).hash = "c0ed3d1334a535f752062c6479ed3a7e";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<3f819f2e923960fed57f95a4535782be>>
* @generated SignedSource<<4e559f39bd9ec2b3782113ef034cad03>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
export type DeletePolicyInput = {
policyId: string;
};
export type PolicyOverviewPageDeleteMutation$variables = {
export type PolicyViewDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeletePolicyInput;
};
export type PolicyOverviewPageDeleteMutation$data = {
export type PolicyViewDeleteMutation$data = {
readonly deletePolicy: {
readonly deletedPolicyId: string;
};
};
export type PolicyOverviewPageDeleteMutation = {
response: PolicyOverviewPageDeleteMutation$data;
variables: PolicyOverviewPageDeleteMutation$variables;
export type PolicyViewDeleteMutation = {
response: PolicyViewDeleteMutation$data;
variables: PolicyViewDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -59,7 +59,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "PolicyOverviewPageDeleteMutation",
"name": "PolicyViewDeleteMutation",
"selections": [
{
"alias": null,
@@ -84,7 +84,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "PolicyOverviewPageDeleteMutation",
"name": "PolicyViewDeleteMutation",
"selections": [
{
"alias": null,
@@ -117,16 +117,16 @@ return {
]
},
"params": {
"cacheID": "0a54108fe8b1d0e8c306ede62182d0b4",
"cacheID": "67e67147e0c7ec0489346b1ad86899c9",
"id": null,
"metadata": {},
"name": "PolicyOverviewPageDeleteMutation",
"name": "PolicyViewDeleteMutation",
"operationKind": "mutation",
"text": "mutation PolicyOverviewPageDeleteMutation(\n $input: DeletePolicyInput!\n) {\n deletePolicy(input: $input) {\n deletedPolicyId\n }\n}\n"
"text": "mutation PolicyViewDeleteMutation(\n $input: DeletePolicyInput!\n) {\n deletePolicy(input: $input) {\n deletedPolicyId\n }\n}\n"
}
};
})();
(node as any).hash = "2ca6b448cda30ef9d66197344469c15b";
(node as any).hash = "7d421e9b068f3f676a4c3069769b1c18";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<67ee63bc3751efdd9ecdf2ad9ab5e197>>
* @generated SignedSource<<eaeef5694bdd615c03bea3b3f8957df3>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,10 +10,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type PolicyStatus = "ACTIVE" | "DRAFT";
export type PolicyOverviewPageQuery$variables = {
export type PolicyViewQuery$variables = {
policyId: string;
};
export type PolicyOverviewPageQuery$data = {
export type PolicyViewQuery$data = {
readonly node: {
readonly content?: string;
readonly createdAt?: string;
@@ -29,9 +29,9 @@ export type PolicyOverviewPageQuery$data = {
readonly updatedAt?: string;
};
};
export type PolicyOverviewPageQuery = {
response: PolicyOverviewPageQuery$data;
variables: PolicyOverviewPageQuery$variables;
export type PolicyViewQuery = {
response: PolicyViewQuery$data;
variables: PolicyViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -136,7 +136,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PolicyOverviewPageQuery",
"name": "PolicyViewQuery",
"selections": [
{
"alias": null,
@@ -159,7 +159,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PolicyOverviewPageQuery",
"name": "PolicyViewQuery",
"selections": [
{
"alias": null,
@@ -184,16 +184,16 @@ return {
]
},
"params": {
"cacheID": "c57fd1a2ff5ffa776bebb9f15bd534bc",
"cacheID": "26bf57eeab9c600a3146e01085ecae8e",
"id": null,
"metadata": {},
"name": "PolicyOverviewPageQuery",
"name": "PolicyViewQuery",
"operationKind": "query",
"text": "query PolicyOverviewPageQuery(\n $policyId: ID!\n) {\n node(id: $policyId) {\n __typename\n id\n ... on Policy {\n name\n content\n createdAt\n updatedAt\n reviewDate\n status\n owner {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n}\n"
"text": "query PolicyViewQuery(\n $policyId: ID!\n) {\n node(id: $policyId) {\n __typename\n id\n ... on Policy {\n name\n content\n createdAt\n updatedAt\n reviewDate\n status\n owner {\n id\n fullName\n primaryEmailAddress\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "e6c4f286358df61d18ea6deb367d7182";
(node as any).hash = "0fcc28fc290df69d14d7d549bd09216f";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<f37225ac5a4b6774dadbd4f23c3bbfe9>>
* @generated SignedSource<<9a97197f1f0eec8c00d0a58e0b07e8bc>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -19,10 +19,10 @@ export type UpdatePolicyInput = {
reviewDate?: string | null | undefined;
status?: PolicyStatus | null | undefined;
};
export type UpdatePolicyPageMutation$variables = {
export type UpdatePolicyViewMutation$variables = {
input: UpdatePolicyInput;
};
export type UpdatePolicyPageMutation$data = {
export type UpdatePolicyViewMutation$data = {
readonly updatePolicy: {
readonly policy: {
readonly content: string;
@@ -38,9 +38,9 @@ export type UpdatePolicyPageMutation$data = {
};
};
};
export type UpdatePolicyPageMutation = {
response: UpdatePolicyPageMutation$data;
variables: UpdatePolicyPageMutation$variables;
export type UpdatePolicyViewMutation = {
response: UpdatePolicyViewMutation$data;
variables: UpdatePolicyViewMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -148,7 +148,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdatePolicyPageMutation",
"name": "UpdatePolicyViewMutation",
"selections": (v2/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -157,20 +157,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdatePolicyPageMutation",
"name": "UpdatePolicyViewMutation",
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "cab3ba1fb88944fade60b5d6a0e0b7b9",
"cacheID": "1ba586009eaf6e79dd2ab5862856cc63",
"id": null,
"metadata": {},
"name": "UpdatePolicyPageMutation",
"name": "UpdatePolicyViewMutation",
"operationKind": "mutation",
"text": "mutation UpdatePolicyPageMutation(\n $input: UpdatePolicyInput!\n) {\n updatePolicy(input: $input) {\n policy {\n id\n name\n content\n status\n version\n reviewDate\n owner {\n id\n fullName\n }\n }\n }\n}\n"
"text": "mutation UpdatePolicyViewMutation(\n $input: UpdatePolicyInput!\n) {\n updatePolicy(input: $input) {\n policy {\n id\n name\n content\n status\n version\n reviewDate\n owner {\n id\n fullName\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "51e4a0e297540f4e83dcb6d809430ac7";
(node as any).hash = "47570dfcceba283c51a4ef2f88143d39";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<0ee0955e5b5136c90cd3230dd2f10163>>
* @generated SignedSource<<b3ede0dedb94e95e89f0d67715a536b3>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,11 +11,11 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PolicyStatus = "ACTIVE" | "DRAFT";
export type UpdatePolicyPageQuery$variables = {
export type UpdatePolicyViewQuery$variables = {
organizationId: string;
policyId: string;
};
export type UpdatePolicyPageQuery$data = {
export type UpdatePolicyViewQuery$data = {
readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleSelector_organization">;
};
@@ -32,9 +32,9 @@ export type UpdatePolicyPageQuery$data = {
readonly version?: number;
};
};
export type UpdatePolicyPageQuery = {
response: UpdatePolicyPageQuery$data;
variables: UpdatePolicyPageQuery$variables;
export type UpdatePolicyViewQuery = {
response: UpdatePolicyViewQuery$data;
variables: UpdatePolicyViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -161,7 +161,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "UpdatePolicyPageQuery",
"name": "UpdatePolicyViewQuery",
"selections": [
{
"alias": "policy",
@@ -203,7 +203,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "UpdatePolicyPageQuery",
"name": "UpdatePolicyViewQuery",
"selections": [
{
"alias": "policy",
@@ -328,16 +328,16 @@ return {
]
},
"params": {
"cacheID": "09418de2f0938515d2f5f67876a1cfaf",
"cacheID": "16bd0bbc57d75f5c4d40eccd560b3da5",
"id": null,
"metadata": {},
"name": "UpdatePolicyPageQuery",
"name": "UpdatePolicyViewQuery",
"operationKind": "query",
"text": "query UpdatePolicyPageQuery(\n $policyId: ID!\n $organizationId: ID!\n) {\n policy: node(id: $policyId) {\n __typename\n id\n ... on Policy {\n name\n content\n status\n version\n reviewDate\n owner {\n id\n fullName\n }\n }\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 UpdatePolicyViewQuery(\n $policyId: ID!\n $organizationId: ID!\n) {\n policy: node(id: $policyId) {\n __typename\n id\n ... on Policy {\n name\n content\n status\n version\n reviewDate\n owner {\n id\n fullName\n }\n }\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"
}
};
})();
(node as any).hash = "abba76ee5208701e443130d650b5c8d1";
(node as any).hash = "1494ae295ead283c9784cc748c5536f7";
export default node;

View File

@@ -5,7 +5,7 @@ import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const VendorListView = lazy(() => import("./VendorListView"));
export function VendorListSkeleton() {
export function VendorListViewSkeleton() {
return (
<PageTemplateSkeleton
title="Vendors"
@@ -36,7 +36,7 @@ export function VendorListPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<VendorListSkeleton />}>
<Suspense key={location.pathname} fallback={<VendorListViewSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorListView />
</ErrorBoundaryWithLocation>

View File

@@ -17,7 +17,7 @@ import { Link } from "react-router";
import Fuse from "fuse.js";
import { useToast } from "@/hooks/use-toast";
import { PageTemplate } from "@/components/PageTemplate";
import { VendorListSkeleton } from "./VendorListPage";
import { VendorListViewSkeleton } from "./VendorListPage";
import { VendorListViewQuery as VendorListViewQueryType } from "./__generated__/VendorListViewQuery.graphql";
import { VendorListViewCreateVendorMutation } from "./__generated__/VendorListViewCreateVendorMutation.graphql";
import { VendorListViewDeleteVendorMutation } from "./__generated__/VendorListViewDeleteVendorMutation.graphql";
@@ -478,11 +478,11 @@ export default function VendorListView() {
}, [loadQuery, organizationId]);
if (!queryRef) {
return <VendorListSkeleton />;
return <VendorListViewSkeleton />;
}
return (
<Suspense fallback={<VendorListSkeleton />}>
<Suspense fallback={<VendorListViewSkeleton />}>
<VendorListContent queryRef={queryRef} />
</Suspense>
);

View File

@@ -0,0 +1,34 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const VendorView = lazy(() => import("./VendorView"));
export function VendorViewSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</PageTemplateSkeleton>
);
}
export function VendorPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<VendorViewSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -14,13 +14,14 @@ import {
useMutation,
} from "react-relay";
import { Suspense, useEffect, useState, useCallback } from "react";
import type { VendorOverviewPageQuery as VendorOverviewPageQueryType } from "./__generated__/VendorOverviewPageQuery.graphql";
import type { VendorViewQuery as VendorViewQueryType } from "./__generated__/VendorViewQuery.graphql";
import { useParams } from "react-router";
import { cn } from "@/lib/utils";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageTemplate } from "@/components/PageTemplate";
import { VendorViewSkeleton } from "./VendorPage";
const vendorOverviewPageQuery = graphql`
query VendorOverviewPageQuery($vendorId: ID!) {
const vendorViewQuery = graphql`
query VendorViewQuery($vendorId: ID!) {
node(id: $vendorId) {
... on Vendor {
id
@@ -42,7 +43,7 @@ const vendorOverviewPageQuery = graphql`
`;
const updateVendorMutation = graphql`
mutation VendorOverviewPageUpdateVendorMutation($input: UpdateVendorInput!) {
mutation VendorViewUpdateVendorMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor {
id
@@ -106,12 +107,12 @@ function formatDateForAPI(dateStr: string): string {
return date.toISOString();
}
function VendorOverviewPageContent({
function VendorViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<VendorOverviewPageQueryType>;
queryRef: PreloadedQuery<VendorViewQueryType>;
}) {
const data = usePreloadedQuery(vendorOverviewPageQuery, queryRef);
const data = usePreloadedQuery(vendorViewQuery, queryRef);
const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
name: data.node.name || "",
@@ -126,9 +127,7 @@ function VendorOverviewPageContent({
privacyPolicyUrl: data.node.privacyPolicyUrl || "",
});
const [commit] = useMutation(updateVendorMutation);
const [, loadQuery] = useQueryLoader<VendorOverviewPageQueryType>(
vendorOverviewPageQuery
);
const [, loadQuery] = useQueryLoader<VendorViewQueryType>(vendorViewQuery);
const { toast } = useToast();
const hasChanges = editedFields.size > 0;
@@ -393,39 +392,22 @@ function VendorOverviewPageContent({
);
}
export function VendorOverviewPageSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</PageTemplateSkeleton>
);
}
export default function VendorOverviewPage() {
export default function VendorView() {
const { vendorId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<VendorOverviewPageQueryType>(
vendorOverviewPageQuery
);
const [queryRef, loadQuery] =
useQueryLoader<VendorViewQueryType>(vendorViewQuery);
useEffect(() => {
loadQuery({ vendorId: vendorId! });
}, [loadQuery, vendorId]);
if (!queryRef) {
return <VendorOverviewPageSkeleton />;
return <VendorViewSkeleton />;
}
return (
<Suspense fallback={<VendorOverviewPageSkeleton />}>
<VendorOverviewPageContent queryRef={queryRef} />
<Suspense fallback={<VendorViewSkeleton />}>
<VendorViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<e6f91ab15045c3a5139af514ed4fe3c1>>
* @generated SignedSource<<dd3ffb0b93fc3b99db6d95ae1074922b>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,10 +11,10 @@
import { ConcreteRequest } from 'relay-runtime';
export type RiskTier = "CRITICAL" | "GENERAL" | "SIGNIFICANT";
export type ServiceCriticality = "HIGH" | "LOW" | "MEDIUM";
export type VendorOverviewPageQuery$variables = {
export type VendorViewQuery$variables = {
vendorId: string;
};
export type VendorOverviewPageQuery$data = {
export type VendorViewQuery$data = {
readonly node: {
readonly createdAt?: string;
readonly description?: string;
@@ -31,9 +31,9 @@ export type VendorOverviewPageQuery$data = {
readonly version?: number;
};
};
export type VendorOverviewPageQuery = {
response: VendorOverviewPageQuery$data;
variables: VendorOverviewPageQuery$variables;
export type VendorViewQuery = {
response: VendorViewQuery$data;
variables: VendorViewQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -147,7 +147,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "VendorOverviewPageQuery",
"name": "VendorViewQuery",
"selections": [
{
"alias": null,
@@ -188,7 +188,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "VendorOverviewPageQuery",
"name": "VendorViewQuery",
"selections": [
{
"alias": null,
@@ -231,16 +231,16 @@ return {
]
},
"params": {
"cacheID": "9163462a149138110e8573e0b28350f9",
"cacheID": "3569222e84bb1fa070b4ce2145d677ac",
"id": null,
"metadata": {},
"name": "VendorOverviewPageQuery",
"name": "VendorViewQuery",
"operationKind": "query",
"text": "query VendorOverviewPageQuery(\n $vendorId: ID!\n) {\n node(id: $vendorId) {\n __typename\n ... on Vendor {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n createdAt\n updatedAt\n version\n }\n id\n }\n}\n"
"text": "query VendorViewQuery(\n $vendorId: ID!\n) {\n node(id: $vendorId) {\n __typename\n ... on Vendor {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n createdAt\n updatedAt\n version\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "56567f61e1016e70d6993ac132ca8382";
(node as any).hash = "9cdc25f48997786054c22246fb417db8";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<918d7356c117ff6830c2c41b7db72ddd>>
* @generated SignedSource<<8799e334baf7ee70b0a03519f6da8f97>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -24,10 +24,10 @@ export type UpdateVendorInput = {
statusPageUrl?: string | null | undefined;
termsOfServiceUrl?: string | null | undefined;
};
export type VendorOverviewPageUpdateVendorMutation$variables = {
export type VendorViewUpdateVendorMutation$variables = {
input: UpdateVendorInput;
};
export type VendorOverviewPageUpdateVendorMutation$data = {
export type VendorViewUpdateVendorMutation$data = {
readonly updateVendor: {
readonly vendor: {
readonly description: string;
@@ -45,9 +45,9 @@ export type VendorOverviewPageUpdateVendorMutation$data = {
};
};
};
export type VendorOverviewPageUpdateVendorMutation = {
response: VendorOverviewPageUpdateVendorMutation$data;
variables: VendorOverviewPageUpdateVendorMutation$variables;
export type VendorViewUpdateVendorMutation = {
response: VendorViewUpdateVendorMutation$data;
variables: VendorViewUpdateVendorMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -177,7 +177,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "VendorOverviewPageUpdateVendorMutation",
"name": "VendorViewUpdateVendorMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -186,20 +186,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "VendorOverviewPageUpdateVendorMutation",
"name": "VendorViewUpdateVendorMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "30449b25e96907e852ae1638ab425111",
"cacheID": "d6de753f4ecc8f59921e246e178bf8a0",
"id": null,
"metadata": {},
"name": "VendorOverviewPageUpdateVendorMutation",
"name": "VendorViewUpdateVendorMutation",
"operationKind": "mutation",
"text": "mutation VendorOverviewPageUpdateVendorMutation(\n $input: UpdateVendorInput!\n) {\n updateVendor(input: $input) {\n vendor {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n updatedAt\n version\n }\n }\n}\n"
"text": "mutation VendorViewUpdateVendorMutation(\n $input: UpdateVendorInput!\n) {\n updateVendor(input: $input) {\n vendor {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n updatedAt\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "e07426684611544b6f4c8d1668a19794";
(node as any).hash = "836cf8657449473503596456b8deb873";
export default node;