Add update control

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-27 15:37:10 +01:00
parent 52de0387fa
commit 40ee608005
13 changed files with 1456 additions and 202 deletions

View File

@@ -24,7 +24,7 @@ posthog.init(process.env.POSTHOG_KEY!, {
});
const OrganizationSelectionPage = lazy(
() => import("./pages/OrganizationSelectionPage"),
() => import("./pages/OrganizationSelectionPage")
);
const HomePage = lazy(() => import("./pages/HomePage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
@@ -35,18 +35,19 @@ const VendorOverviewPage = lazy(() => import("./pages/VendorOverviewPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const CreatePeoplePage = lazy(() => import("./pages/CreatePeoplePage"));
const FrameworkOverviewPage = lazy(
() => import("./pages/FrameworkOverviewPage"),
() => import("./pages/FrameworkOverviewPage")
);
const ControlOverviewPage = lazy(() => import("./pages/ControlOverviewPage"));
const PeopleOverviewPage = lazy(() => import("./pages/PeopleOverviewPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const CreateOrganizationPage = lazy(
() => import("./pages/CreateOrganizationPage"),
() => import("./pages/CreateOrganizationPage")
);
const CreateFrameworkPage = lazy(() => import("./pages/CreateFrameworkPage"));
const CreateControlPage = lazy(() => import("./pages/CreateControlPage"));
const UpdateFrameworkPage = lazy(() => import("./pages/UpdateFrameworkPage"));
const UpdateControlPage = lazy(() => import("./pages/UpdateControlPage"));
function App() {
return (
@@ -261,6 +262,16 @@ function App() {
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={

View File

@@ -1,5 +1,5 @@
import { Suspense, useEffect, useState } from "react";
import { useParams } from "react-router";
import { useParams, useNavigate } from "react-router";
import {
graphql,
PreloadedQuery,
@@ -102,12 +102,14 @@ function ControlOverviewPageContent({
}) {
const data = usePreloadedQuery<ControlOverviewPageQueryType>(
controlOverviewPageQuery,
queryRef,
queryRef
);
const { toast } = useToast();
const { organizationId, frameworkId, controlId } = useParams();
const navigate = useNavigate();
const [updateTaskState] =
useMutation<ControlOverviewPageUpdateTaskStateMutationType>(
updateTaskStateMutation,
updateTaskStateMutation
);
const [createTask] =
useMutation<ControlOverviewPageCreateTaskMutationType>(createTaskMutation);
@@ -244,201 +246,222 @@ function ControlOverviewPageContent({
});
};
const handleEditControl = () => {
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}/update`
);
};
return (
<div className="min-h-screen bg-white p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{control?.name}</h1>
<div className="flex items-center gap-2">
<div className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm">
30 min
</div>
<div className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
Mandatory
</div>
<div className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
Assigned to you
</div>
</div>
</div>
<p className="text-gray-600 max-w-3xl">{control?.description}</p>
</div>
<Card className="bg-gray-50 border border-gray-200">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-4 h-4 rounded-full bg-white flex items-center justify-center border border-gray-200">
<div
className={`w-2 h-2 rounded-full ${
control?.state === "IMPLEMENTED"
? "bg-green-500"
: "bg-gray-300"
}`}
/>
</div>
<span className="text-sm text-gray-700">
{control?.state === "IMPLEMENTED" ? "Validated" : "Not validated"}
</span>
</div>
</CardContent>
</Card>
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Tasks</h2>
<Dialog open={isCreateTaskOpen} onOpenChange={setIsCreateTaskOpen}>
<DialogTrigger asChild>
<Button size="sm" className="flex items-center gap-1">
<Plus className="w-4 h-4" />
<span>Add Task</span>
<>
<Helmet>
<title>{control?.name || "Control"} - Probo</title>
</Helmet>
<div className="min-h-screen bg-white p-6 space-y-6">
<div className="space-y-4 mb-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{control?.name}</h1>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleEditControl}>
Edit Control
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
<DialogDescription>
Add a new task to this control. Click save when you&apos;re
done.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Task Name
</label>
<Input
id="name"
value={newTaskName}
onChange={(e) => setNewTaskName(e.target.value)}
placeholder="Enter task name"
/>
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description (optional)
</label>
<Input
id="description"
value={newTaskDescription}
onChange={(e) => setNewTaskDescription(e.target.value)}
placeholder="Enter task description"
/>
</div>
<div className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm">
30 min
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateTaskOpen(false)}
>
Cancel
</Button>
<Button onClick={handleCreateTask}>Create Task</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
Mandatory
</div>
<div className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
Assigned to you
</div>
</div>
</div>
<p className="text-gray-600 max-w-3xl">{control?.description}</p>
</div>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task?.id}
className="flex items-center gap-3 py-4 px-2 hover:bg-gray-50 group"
>
<Card className="bg-gray-50 border border-gray-200">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div
className={`w-5 h-5 rounded border flex items-center justify-center cursor-pointer ${
task?.state === "DONE"
? "border-gray-400 bg-gray-100"
: "border-gray-300"
}`}
onClick={() =>
task?.id &&
task?.state &&
handleTaskClick(task.id, task.state)
}
className={`w-4 h-4 rounded-full bg-white flex items-center justify-center border border-gray-200`}
>
{task?.state === "DONE" && (
<CheckCircle2 className="w-4 h-4 text-gray-500" />
)}
<div
className={`w-2 h-2 rounded-full ${
control?.state === "IMPLEMENTED"
? "bg-green-500"
: "bg-gray-300"
}`}
/>
</div>
<div
className="flex-1 flex items-center justify-between cursor-pointer"
onClick={() =>
task?.id &&
task?.state &&
handleTaskClick(task.id, task.state)
}
>
<div>
<h3
className={`text-sm ${
task?.state === "DONE"
? "text-gray-500 line-through"
: "text-gray-900"
}`}
>
{task?.name}
</h3>
{task?.description && (
<p
className={`text-xs mt-1 ${
task?.state === "DONE"
? "text-gray-400 line-through"
: "text-gray-500"
}`}
<span className="text-sm text-gray-700">
{control?.state === "IMPLEMENTED"
? "Validated"
: "Not validated"}
</span>
</div>
</CardContent>
</Card>
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Tasks</h2>
<Dialog open={isCreateTaskOpen} onOpenChange={setIsCreateTaskOpen}>
<DialogTrigger asChild>
<Button size="sm" className="flex items-center gap-1">
<Plus className="w-4 h-4" />
<span>Add Task</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
<DialogDescription>
Add a new task to this control. Click save when you&apos;re
done.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Task Name
</label>
<Input
id="name"
value={newTaskName}
onChange={(e) => setNewTaskName(e.target.value)}
placeholder="Enter task name"
/>
</div>
<div className="space-y-2">
<label
htmlFor="description"
className="text-sm font-medium"
>
{task.description}
</p>
Description (optional)
</label>
<Input
id="description"
value={newTaskDescription}
onChange={(e) => setNewTaskDescription(e.target.value)}
placeholder="Enter task description"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateTaskOpen(false)}
>
Cancel
</Button>
<Button onClick={handleCreateTask}>Create Task</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task?.id}
className="flex items-center gap-3 py-4 px-2 hover:bg-gray-50 group"
>
<div
className={`w-5 h-5 rounded border flex items-center justify-center cursor-pointer ${
task?.state === "DONE"
? "border-gray-400 bg-gray-100"
: "border-gray-300"
}`}
onClick={() =>
task?.id &&
task?.state &&
handleTaskClick(task.id, task.state)
}
>
{task?.state === "DONE" && (
<CheckCircle2 className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-gray-400 text-sm">06.00 - 07.30</div>
<button
className="text-gray-400 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
if (task?.id && task?.name) {
handleDeleteTask(task.id, task.name);
}
}}
>
<Trash2 className="w-4 h-4" />
</button>
<div
className="flex-1 flex items-center justify-between cursor-pointer"
onClick={() =>
task?.id &&
task?.state &&
handleTaskClick(task.id, task.state)
}
>
<div>
<h3
className={`text-sm ${
task?.state === "DONE"
? "text-gray-500 line-through"
: "text-gray-900"
}`}
>
{task?.name}
</h3>
{task?.description && (
<p
className={`text-xs mt-1 ${
task?.state === "DONE"
? "text-gray-400 line-through"
: "text-gray-500"
}`}
>
{task.description}
</p>
)}
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-gray-400 text-sm">06.00 - 07.30</div>
<button
className="text-gray-400 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
if (task?.id && task?.name) {
handleDeleteTask(task.id, task.name);
}
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
))}
))}
{tasks.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p>No tasks yet. Click &quot;Add Task&quot; to create one.</p>
</div>
)}
{tasks.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p>No tasks yet. Click &quot;Add Task&quot; to create one.</p>
</div>
)}
</div>
</div>
</div>
{/* Delete Task Confirmation Dialog */}
<Dialog open={isDeleteTaskOpen} onOpenChange={setIsDeleteTaskOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Task</DialogTitle>
<DialogDescription>
Are you sure you want to delete the task &quot;
{taskToDelete?.name}&quot;? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteTaskOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDeleteTask}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Delete Task Confirmation Dialog */}
<Dialog open={isDeleteTaskOpen} onOpenChange={setIsDeleteTaskOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Task</DialogTitle>
<DialogDescription>
Are you sure you want to delete the task &quot;
{taskToDelete?.name}&quot;? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteTaskOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDeleteTask}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}
@@ -466,21 +489,22 @@ function ControlOverviewPageFallback() {
export default function ControlOverviewPage() {
const { controlId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<ControlOverviewPageQueryType>(
controlOverviewPageQuery,
controlOverviewPageQuery
);
useEffect(() => {
loadQuery({ controlId: controlId! });
}, [loadQuery, controlId]);
if (controlId) {
loadQuery({ controlId });
}
}, [controlId, loadQuery]);
if (!queryRef) {
return <ControlOverviewPageFallback />;
}
return (
<>
<Helmet>
<title>Control Overview - Probo Console</title>
</Helmet>
<Suspense fallback={<ControlOverviewPageFallback />}>
{queryRef && <ControlOverviewPageContent queryRef={queryRef} />}
</Suspense>
</>
<Suspense fallback={<ControlOverviewPageFallback />}>
<ControlOverviewPageContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,335 @@
import { Suspense, useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Helmet } from "react-helmet-async";
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";
// Type imports will be available after Relay compiler runs
// import type { UpdateControlPageQuery as UpdateControlPageQueryType } from "./__generated__/UpdateControlPageQuery.graphql";
// import type { UpdateControlPageUpdateControlMutation as UpdateControlPageUpdateControlMutationType } from "./__generated__/UpdateControlPageUpdateControlMutation.graphql";
const updateControlMutation = graphql`
mutation UpdateControlPageUpdateControlMutation($input: UpdateControlInput!) {
updateControl(input: $input) {
control {
id
name
description
category
state
version
}
}
}
`;
const updateControlQuery = graphql`
query UpdateControlPageQuery($controlId: ID!) {
node(id: $controlId) {
... on Control {
id
name
description
category
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: "",
});
useEffect(() => {
if (data.node) {
setFormData({
name: data.node.name || "",
description: data.node.description || "",
category: data.node.category || "",
state: data.node.state || "",
});
}
}, [data.node]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [commit, isInFlight] = useMutation<any>(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?: string;
} = {
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;
}
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 (
<>
<Helmet>
<title>Update Control - Probo</title>
</Helmet>
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold">Update Control</h1>
<p className="text-muted-foreground">Update the control details</p>
</div>
<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="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>
</div>
</>
);
}
function UpdateControlPageFallback() {
return <div>Loading...</div>;
}
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 <UpdateControlPageFallback />;
}
return (
<Suspense fallback={<UpdateControlPageFallback />}>
<UpdateControlPageContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,175 @@
/**
* @generated SignedSource<<daddf46b2039f01b80967778f24dd7f4>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type UpdateControlPageQuery$variables = {
controlId: string;
};
export type UpdateControlPageQuery$data = {
readonly node: {
readonly category?: string;
readonly description?: string;
readonly id?: string;
readonly name?: string;
readonly state?: ControlState;
readonly version?: number;
};
};
export type UpdateControlPageQuery = {
response: UpdateControlPageQuery$data;
variables: UpdateControlPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "controlId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "controlId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "version",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateControlPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v7/*: any*/)
],
"type": "Control",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateControlPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v7/*: any*/)
],
"type": "Control",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "c44526d8079e0c539d634841a1f14923",
"id": null,
"metadata": {},
"name": "UpdateControlPageQuery",
"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 state\n version\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "d0a9cc6ebcb5b97ea57567edea1f7d5f";
export default node;

View File

@@ -0,0 +1,151 @@
/**
* @generated SignedSource<<81cac01f1ba6637b3367363835258e4c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type UpdateControlInput = {
category?: string | null | undefined;
description?: string | null | undefined;
expectedVersion: number;
id: string;
name?: string | null | undefined;
state?: ControlState | null | undefined;
};
export type UpdateControlPageUpdateControlMutation$variables = {
input: UpdateControlInput;
};
export type UpdateControlPageUpdateControlMutation$data = {
readonly updateControl: {
readonly control: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: ControlState;
readonly version: number;
};
};
};
export type UpdateControlPageUpdateControlMutation = {
response: UpdateControlPageUpdateControlMutation$data;
variables: UpdateControlPageUpdateControlMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateControlPayload",
"kind": "LinkedField",
"name": "updateControl",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "control",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "version",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateControlPageUpdateControlMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateControlPageUpdateControlMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "1e9e2b8000a1b3061fcfda07260413ed",
"id": null,
"metadata": {},
"name": "UpdateControlPageUpdateControlMutation",
"operationKind": "mutation",
"text": "mutation UpdateControlPageUpdateControlMutation(\n $input: UpdateControlInput!\n) {\n updateControl(input: $input) {\n control {\n id\n name\n description\n category\n state\n version\n }\n }\n}\n"
}
};
})();
(node as any).hash = "861813b150cef2977f8d9455a9bcf4fa";
export default node;

View File

@@ -212,6 +212,7 @@ type ControlEdge {
type Control implements Node {
id: ID!
version: Int!
category: String!
name: String!
description: String!
@@ -399,6 +400,7 @@ type Mutation {
createFramework(input: CreateFrameworkInput!): CreateFrameworkPayload!
createControl(input: CreateControlInput!): CreateControlPayload!
updateFramework(input: UpdateFrameworkInput!): UpdateFrameworkPayload!
updateControl(input: UpdateControlInput!): UpdateControlPayload!
}
input CreateVendorInput {
@@ -585,3 +587,16 @@ type UpdateVendorPayload {
type UpdatePeoplePayload {
people: People!
}
input UpdateControlInput {
id: ID!
expectedVersion: Int!
name: String
description: String
category: String
state: ControlState
}
type UpdateControlPayload {
control: Control!
}

View File

@@ -66,6 +66,7 @@ type ComplexityRoot struct {
StateTransisions func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
Tasks func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
UpdatedAt func(childComplexity int) int
Version func(childComplexity int) int
}
ControlConnection struct {
@@ -208,6 +209,7 @@ type ComplexityRoot struct {
DeletePeople func(childComplexity int, input types.DeletePeopleInput) int
DeleteTask func(childComplexity int, input types.DeleteTaskInput) int
DeleteVendor func(childComplexity int, input types.DeleteVendorInput) int
UpdateControl func(childComplexity int, input types.UpdateControlInput) int
UpdateFramework func(childComplexity int, input types.UpdateFrameworkInput) int
UpdatePeople func(childComplexity int, input types.UpdatePeopleInput) int
UpdateTaskState func(childComplexity int, input types.UpdateTaskStateInput) int
@@ -313,6 +315,10 @@ type ComplexityRoot struct {
Node func(childComplexity int) int
}
UpdateControlPayload struct {
Control func(childComplexity int) int
}
UpdateFrameworkPayload struct {
Framework func(childComplexity int) int
}
@@ -390,6 +396,7 @@ type MutationResolver interface {
CreateFramework(ctx context.Context, input types.CreateFrameworkInput) (*types.CreateFrameworkPayload, error)
CreateControl(ctx context.Context, input types.CreateControlInput) (*types.CreateControlPayload, error)
UpdateFramework(ctx context.Context, input types.UpdateFrameworkInput) (*types.UpdateFrameworkPayload, error)
UpdateControl(ctx context.Context, input types.UpdateControlInput) (*types.UpdateControlPayload, error)
}
type OrganizationResolver interface {
Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error)
@@ -500,6 +507,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Control.UpdatedAt(childComplexity), true
case "Control.version":
if e.complexity.Control.Version == nil {
break
}
return e.complexity.Control.Version(childComplexity), true
case "ControlConnection.edges":
if e.complexity.ControlConnection.Edges == nil {
break
@@ -1029,6 +1043,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.DeleteVendor(childComplexity, args["input"].(types.DeleteVendorInput)), true
case "Mutation.updateControl":
if e.complexity.Mutation.UpdateControl == nil {
break
}
args, err := ec.field_Mutation_updateControl_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.UpdateControl(childComplexity, args["input"].(types.UpdateControlInput)), true
case "Mutation.updateFramework":
if e.complexity.Mutation.UpdateFramework == nil {
break
@@ -1485,6 +1511,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.TaskStateTransitionEdge.Node(childComplexity), true
case "UpdateControlPayload.control":
if e.complexity.UpdateControlPayload.Control == nil {
break
}
return e.complexity.UpdateControlPayload.Control(childComplexity), true
case "UpdateFrameworkPayload.framework":
if e.complexity.UpdateFrameworkPayload.Framework == nil {
break
@@ -1697,6 +1730,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec.unmarshalInputDeletePeopleInput,
ec.unmarshalInputDeleteTaskInput,
ec.unmarshalInputDeleteVendorInput,
ec.unmarshalInputUpdateControlInput,
ec.unmarshalInputUpdateFrameworkInput,
ec.unmarshalInputUpdatePeopleInput,
ec.unmarshalInputUpdateTaskStateInput,
@@ -2012,6 +2046,7 @@ type ControlEdge {
type Control implements Node {
id: ID!
version: Int!
category: String!
name: String!
description: String!
@@ -2199,6 +2234,7 @@ type Mutation {
createFramework(input: CreateFrameworkInput!): CreateFrameworkPayload!
createControl(input: CreateControlInput!): CreateControlPayload!
updateFramework(input: UpdateFrameworkInput!): UpdateFrameworkPayload!
updateControl(input: UpdateControlInput!): UpdateControlPayload!
}
input CreateVendorInput {
@@ -2385,6 +2421,19 @@ type UpdateVendorPayload {
type UpdatePeoplePayload {
people: People!
}
input UpdateControlInput {
id: ID!
expectedVersion: Int!
name: String
description: String
category: String
state: ControlState
}
type UpdateControlPayload {
control: Control!
}
`, BuiltIn: false},
}
var parsedSchema = gqlparser.MustLoadSchema(sources...)
@@ -2931,6 +2980,29 @@ func (ec *executionContext) field_Mutation_deleteVendor_argsInput(
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_updateControl_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := ec.field_Mutation_updateControl_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_updateControl_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.UpdateControlInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNUpdateControlInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateControlInput(ctx, tmp)
}
var zeroVal types.UpdateControlInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_updateFramework_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -3623,6 +3695,44 @@ func (ec *executionContext) fieldContext_Control_id(_ context.Context, field gra
return fc, nil
}
func (ec *executionContext) _Control_version(ctx context.Context, field graphql.CollectedField, obj *types.Control) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Control_version(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Version, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(int)
fc.Result = res
return ec.marshalNInt2int(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Control_version(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Control",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Control_category(ctx context.Context, field graphql.CollectedField, obj *types.Control) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Control_category(ctx, field)
if err != nil {
@@ -4114,6 +4224,8 @@ func (ec *executionContext) fieldContext_ControlEdge_node(_ context.Context, fie
switch field.Name {
case "id":
return ec.fieldContext_Control_id(ctx, field)
case "version":
return ec.fieldContext_Control_version(ctx, field)
case "category":
return ec.fieldContext_Control_category(ctx, field)
case "name":
@@ -6981,6 +7093,53 @@ func (ec *executionContext) fieldContext_Mutation_updateFramework(ctx context.Co
return fc, nil
}
func (ec *executionContext) _Mutation_updateControl(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_updateControl(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().UpdateControl(rctx, fc.Args["input"].(types.UpdateControlInput))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.UpdateControlPayload)
fc.Result = res
return ec.marshalNUpdateControlPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateControlPayload(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_updateControl(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Mutation",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "control":
return ec.fieldContext_UpdateControlPayload_control(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type UpdateControlPayload", field.Name)
},
}
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_updateControl_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Organization_id(ctx, field)
if err != nil {
@@ -9338,6 +9497,66 @@ func (ec *executionContext) fieldContext_TaskStateTransitionEdge_node(_ context.
return fc, nil
}
func (ec *executionContext) _UpdateControlPayload_control(ctx context.Context, field graphql.CollectedField, obj *types.UpdateControlPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_UpdateControlPayload_control(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Control, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.Control)
fc.Result = res
return ec.marshalNControl2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐControl(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_UpdateControlPayload_control(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "UpdateControlPayload",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "id":
return ec.fieldContext_Control_id(ctx, field)
case "version":
return ec.fieldContext_Control_version(ctx, field)
case "category":
return ec.fieldContext_Control_category(ctx, field)
case "name":
return ec.fieldContext_Control_name(ctx, field)
case "description":
return ec.fieldContext_Control_description(ctx, field)
case "state":
return ec.fieldContext_Control_state(ctx, field)
case "stateTransisions":
return ec.fieldContext_Control_stateTransisions(ctx, field)
case "tasks":
return ec.fieldContext_Control_tasks(ctx, field)
case "createdAt":
return ec.fieldContext_Control_createdAt(ctx, field)
case "updatedAt":
return ec.fieldContext_Control_updatedAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type Control", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _UpdateFrameworkPayload_framework(ctx context.Context, field graphql.CollectedField, obj *types.UpdateFrameworkPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_UpdateFrameworkPayload_framework(ctx, field)
if err != nil {
@@ -12448,6 +12667,68 @@ func (ec *executionContext) unmarshalInputDeleteVendorInput(ctx context.Context,
return it, nil
}
func (ec *executionContext) unmarshalInputUpdateControlInput(ctx context.Context, obj any) (types.UpdateControlInput, error) {
var it types.UpdateControlInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"id", "expectedVersion", "name", "description", "category", "state"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
continue
}
switch k {
case "id":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v)
if err != nil {
return it, err
}
it.ID = data
case "expectedVersion":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("expectedVersion"))
data, err := ec.unmarshalNInt2int(ctx, v)
if err != nil {
return it, err
}
it.ExpectedVersion = data
case "name":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name"))
data, err := ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
it.Name = data
case "description":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("description"))
data, err := ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
it.Description = data
case "category":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("category"))
data, err := ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
it.Category = data
case "state":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("state"))
data, err := ec.unmarshalOControlState2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐControlState(ctx, v)
if err != nil {
return it, err
}
it.State = data
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputUpdateFrameworkInput(ctx context.Context, obj any) (types.UpdateFrameworkInput, error) {
var it types.UpdateFrameworkInput
asMap := map[string]any{}
@@ -12778,6 +13059,11 @@ func (ec *executionContext) _Control(ctx context.Context, sel ast.SelectionSet,
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "version":
out.Values[i] = ec._Control_version(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "category":
out.Values[i] = ec._Control_category(ctx, field, obj)
if out.Values[i] == graphql.Null {
@@ -14151,6 +14437,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "updateControl":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_updateControl(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -15112,6 +15405,45 @@ func (ec *executionContext) _TaskStateTransitionEdge(ctx context.Context, sel as
return out
}
var updateControlPayloadImplementors = []string{"UpdateControlPayload"}
func (ec *executionContext) _UpdateControlPayload(ctx context.Context, sel ast.SelectionSet, obj *types.UpdateControlPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, updateControlPayloadImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("UpdateControlPayload")
case "control":
out.Values[i] = ec._UpdateControlPayload_control(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var updateFrameworkPayloadImplementors = []string{"UpdateFrameworkPayload"}
func (ec *executionContext) _UpdateFrameworkPayload(ctx context.Context, sel ast.SelectionSet, obj *types.UpdateFrameworkPayload) graphql.Marshaler {
@@ -17011,6 +17343,25 @@ func (ec *executionContext) marshalNTaskStateTransitionEdge2ᚖgithubᚗcomᚋge
return ec._TaskStateTransitionEdge(ctx, sel, v)
}
func (ec *executionContext) unmarshalNUpdateControlInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateControlInput(ctx context.Context, v any) (types.UpdateControlInput, error) {
res, err := ec.unmarshalInputUpdateControlInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNUpdateControlPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateControlPayload(ctx context.Context, sel ast.SelectionSet, v types.UpdateControlPayload) graphql.Marshaler {
return ec._UpdateControlPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNUpdateControlPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateControlPayload(ctx context.Context, sel ast.SelectionSet, v *types.UpdateControlPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._UpdateControlPayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNUpdateFrameworkInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateFrameworkInput(ctx context.Context, v any) (types.UpdateFrameworkInput, error) {
res, err := ec.unmarshalInputUpdateFrameworkInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)

View File

@@ -42,6 +42,7 @@ func NewControlEdge(c *coredata.Control) *ControlEdge {
func NewControl(c *coredata.Control) *Control {
return &Control{
ID: c.ID,
Version: c.Version,
Category: c.Category,
Name: c.Name,
Description: c.Description,

View File

@@ -17,6 +17,7 @@ type Node interface {
type Control struct {
ID gid.GID `json:"id"`
Version int `json:"version"`
Category string `json:"category"`
Name string `json:"name"`
Description string `json:"description"`
@@ -334,6 +335,19 @@ type TaskStateTransitionEdge struct {
Node *TaskStateTransition `json:"node"`
}
type UpdateControlInput struct {
ID gid.GID `json:"id"`
ExpectedVersion int `json:"expectedVersion"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty"`
State *coredata.ControlState `json:"state,omitempty"`
}
type UpdateControlPayload struct {
Control *Control `json:"control"`
}
type UpdateFrameworkInput struct {
ID gid.GID `json:"id"`
ExpectedVersion int `json:"expectedVersion"`

View File

@@ -276,11 +276,19 @@ func (r *mutationResolver) CreateControl(ctx context.Context, input types.Create
// UpdateFramework is the resolver for the updateFramework field.
func (r *mutationResolver) UpdateFramework(ctx context.Context, input types.UpdateFrameworkInput) (*types.UpdateFrameworkPayload, error) {
var name, description *string
if input.Name != nil {
name = input.Name
}
if input.Description != nil {
description = input.Description
}
framework, err := r.proboSvc.UpdateFramework(ctx, probo.UpdateFrameworkRequest{
ID: input.ID,
ExpectedVersion: input.ExpectedVersion,
Name: input.Name,
Description: input.Description,
Name: name,
Description: description,
})
if err != nil {
return nil, fmt.Errorf("cannot update framework: %w", err)
@@ -291,6 +299,41 @@ func (r *mutationResolver) UpdateFramework(ctx context.Context, input types.Upda
}, nil
}
// UpdateControl is the resolver for the updateControl field.
func (r *mutationResolver) UpdateControl(ctx context.Context, input types.UpdateControlInput) (*types.UpdateControlPayload, error) {
var name, description, category *string
var state *coredata.ControlState
if input.Name != nil {
name = input.Name
}
if input.Description != nil {
description = input.Description
}
if input.Category != nil {
category = input.Category
}
if input.State != nil {
state = input.State
}
control, err := r.proboSvc.UpdateControl(ctx, probo.UpdateControlRequest{
ID: input.ID,
ExpectedVersion: input.ExpectedVersion,
Name: name,
Description: description,
Category: category,
State: state,
})
if err != nil {
return nil, fmt.Errorf("cannot update control: %w", err)
}
return &types.UpdateControlPayload{
Control: types.NewControl(control),
}, nil
}
// Frameworks is the resolver for the frameworks field.
func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) {
cursor := types.NewCursor(first, after, last, before)

View File

@@ -16,6 +16,7 @@ package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
@@ -38,9 +39,18 @@ type (
ContentRef string
CreatedAt time.Time
UpdatedAt time.Time
Version int
}
Controls []*Control
UpdateControlParams struct {
ExpectedVersion int
Name *string
Description *string
Category *string
State *ControlState
}
)
func (c Control) CursorKey() page.CursorKey {
@@ -58,6 +68,7 @@ func (c *Control) scan(r pgx.Row) error {
&c.ContentRef,
&c.CreatedAt,
&c.UpdatedAt,
&c.Version,
)
}
@@ -90,7 +101,8 @@ SELECT
cs.to_state AS state,
content_ref,
created_at,
updated_at
updated_at,
version
FROM
controls
INNER JOIN
@@ -133,7 +145,8 @@ INSERT INTO
description,
content_ref,
created_at,
updated_at
updated_at,
version
)
VALUES (
@control_id,
@@ -152,6 +165,7 @@ VALUES (
"framework_id": c.FrameworkID,
"category": c.Category,
"name": c.Name,
"version": 0,
"description": c.Description,
"content_ref": c.ContentRef,
"created_at": c.CreatedAt,
@@ -189,7 +203,8 @@ SELECT
cs.to_state AS state,
content_ref,
created_at,
updated_at
updated_at,
version
FROM
controls
INNER JOIN
@@ -230,3 +245,76 @@ WHERE
return nil
}
func (c *Control) Update(
ctx context.Context,
conn pg.Conn,
scope *Scope,
params UpdateControlParams,
) error {
q := `
WITH control_states AS (
SELECT
control_id,
to_state,
reason,
RANK() OVER w
FROM
control_state_transitions
WINDOW
w AS (PARTITION BY control_id ORDER BY created_at DESC)
)
UPDATE controls SET
name = COALESCE(@name, name),
description = COALESCE(@description, description),
category = COALESCE(@category, category),
updated_at = @updated_at,
version = version + 1
WHERE %s
AND id = @control_id
AND version = @expected_version
RETURNING
id,
framework_id,
category,
name,
description,
(SELECT to_state FROM control_states WHERE control_id = controls.id AND rank = 1) AS state,
content_ref,
created_at,
updated_at,
version
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"control_id": c.ID,
"expected_version": params.ExpectedVersion,
"updated_at": time.Now(),
}
if params.Name != nil {
args["name"] = *params.Name
}
if params.Description != nil {
args["description"] = *params.Description
}
if params.Category != nil {
args["category"] = *params.Category
}
maps.Copy(args, scope.SQLArguments())
r := conn.QueryRow(ctx, q, args)
c2 := Control{}
if err := c2.scan(r); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrConcurrentModification
}
return err
}
*c = c2
return nil
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE controls ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE controls ALTER COLUMN version DROP DEFAULT;

View File

@@ -0,0 +1,44 @@
package probo
import (
"context"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo/coredata"
"go.gearno.de/kit/pg"
)
type UpdateControlRequest struct {
ID gid.GID
ExpectedVersion int
Name *string
Description *string
Category *string
State *coredata.ControlState
}
func (s Service) UpdateControl(
ctx context.Context,
req UpdateControlRequest,
) (*coredata.Control, error) {
params := coredata.UpdateControlParams{
ExpectedVersion: req.ExpectedVersion,
Name: req.Name,
Description: req.Description,
Category: req.Category,
State: req.State,
}
control := &coredata.Control{ID: req.ID}
err := s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
return control.Update(ctx, conn, s.scope, params)
})
if err != nil {
return nil, err
}
return control, nil
}