Fix all lazy suspense callbacks
Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
170
apps/console/src/pages/organizations/CreateOrganizationView.tsx
Normal file
170
apps/console/src/pages/organizations/CreateOrganizationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
542
apps/console/src/pages/organizations/SettingsView.tsx
Normal file
542
apps/console/src/pages/organizations/SettingsView.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
305
apps/console/src/pages/organizations/people/CreatePeopleView.tsx
Normal file
305
apps/console/src/pages/organizations/people/CreatePeopleView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
326
apps/console/src/pages/organizations/people/PeopleListView.tsx
Normal file
326
apps/console/src/pages/organizations/people/PeopleListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/console/src/pages/organizations/people/PeoplePage.tsx
Normal file
36
apps/console/src/pages/organizations/people/PeoplePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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>
|
||||
);
|
||||
}
|
||||
|
||||
370
apps/console/src/pages/organizations/policies/PolicyListView.tsx
Normal file
370
apps/console/src/pages/organizations/policies/PolicyListView.tsx
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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>
|
||||
);
|
||||
}
|
||||
120
apps/console/src/pages/organizations/policies/PolicyPage.tsx
Normal file
120
apps/console/src/pages/organizations/policies/PolicyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
34
apps/console/src/pages/organizations/vendors/VendorPage.tsx
vendored
Normal file
34
apps/console/src/pages/organizations/vendors/VendorPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user