Add update framework

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-27 14:42:49 +01:00
parent c54febadb8
commit c946e51015
16 changed files with 1129 additions and 28 deletions

View File

@@ -46,6 +46,7 @@ const CreateOrganizationPage = lazy(
);
const CreateFrameworkPage = lazy(() => import("./pages/CreateFrameworkPage"));
const CreateControlPage = lazy(() => import("./pages/CreateControlPage"));
const UpdateFrameworkPage = lazy(() => import("./pages/UpdateFrameworkPage"));
function App() {
return (
@@ -230,6 +231,16 @@ function App() {
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/create"
element={

View File

@@ -137,6 +137,48 @@ function BreadcrumbFrameworkOverview() {
);
}
function BreadcrumbUpdateFramework() {
const { organizationId, frameworkId } = useParams();
const data = useLazyLoadQuery<ConsoleLayoutBreadcrumbUpdateFrameworkQuery>(
graphql`
query ConsoleLayoutBreadcrumbUpdateFrameworkQuery($frameworkId: ID!) {
framework: node(id: $frameworkId) {
id
... on Framework {
name
}
}
}
`,
{ frameworkId: frameworkId! },
{ fetchPolicy: "store-or-network" }
);
return (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
asChild
className="max-w-[160px] truncate"
aria-label={data.framework?.name}
>
<Link
to={`/organizations/${organizationId}/frameworks/${frameworkId}`}
>
{data.framework?.name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Update</BreadcrumbPage>
</BreadcrumbItem>
<Outlet />
</>
);
}
function BreadcrumbVendorList() {
const { organizationId } = useParams();
return (
@@ -303,7 +345,6 @@ function BreadcrumbCreateControl() {
graphql`
query ConsoleLayoutBreadcrumbCreateControlQuery($frameworkId: ID!) {
framework: node(id: $frameworkId) {
id
... on Framework {
name
}
@@ -365,6 +406,10 @@ export default function ConsoleLayout() {
element={<BreadcrumbControlOverview />}
/>
</Route>
<Route
path=":frameworkId/update"
element={<BreadcrumbUpdateFramework />}
/>
<Route
path="create"
element={<BreadcrumbCreateFramework />}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<72acdb0b83faf29c9d1a4f403a40ac5e>>
* @generated SignedSource<<051137c0676ed5fd6b8807a44b9cb2a4>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,7 +14,6 @@ export type ConsoleLayoutBreadcrumbCreateControlQuery$variables = {
};
export type ConsoleLayoutBreadcrumbCreateControlQuery$data = {
readonly framework: {
readonly id: string;
readonly name?: string;
};
};
@@ -39,13 +38,6 @@ v1 = [
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"kind": "InlineFragment",
"selections": [
{
@@ -74,8 +66,7 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
(v2/*: any*/)
],
"storageKey": null
}
@@ -105,23 +96,29 @@ return {
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/)
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "96453f593f21e812afc5b888cc68da3e",
"cacheID": "a86ec305f24f6a83d549a31e1469a89c",
"id": null,
"metadata": {},
"name": "ConsoleLayoutBreadcrumbCreateControlQuery",
"operationKind": "query",
"text": "query ConsoleLayoutBreadcrumbCreateControlQuery(\n $frameworkId: ID!\n) {\n framework: node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n }\n }\n}\n"
"text": "query ConsoleLayoutBreadcrumbCreateControlQuery(\n $frameworkId: ID!\n) {\n framework: node(id: $frameworkId) {\n __typename\n ... on Framework {\n name\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "f249019075c60c4449f6f232229a6965";
(node as any).hash = "5033069853aec310ad544974f7e64b5b";
export default node;

View File

@@ -0,0 +1,127 @@
/**
* @generated SignedSource<<d4b3974723a445d3d352518d6244afd2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ConsoleLayoutBreadcrumbUpdateFrameworkQuery$variables = {
frameworkId: string;
};
export type ConsoleLayoutBreadcrumbUpdateFrameworkQuery$data = {
readonly framework: {
readonly id: string;
readonly name?: string;
};
};
export type ConsoleLayoutBreadcrumbUpdateFrameworkQuery = {
response: ConsoleLayoutBreadcrumbUpdateFrameworkQuery$data;
variables: ConsoleLayoutBreadcrumbUpdateFrameworkQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "frameworkId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "frameworkId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"type": "Framework",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ConsoleLayoutBreadcrumbUpdateFrameworkQuery",
"selections": [
{
"alias": "framework",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ConsoleLayoutBreadcrumbUpdateFrameworkQuery",
"selections": [
{
"alias": "framework",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "194d6a240ee1e2f0ef799e08bc0e44f5",
"id": null,
"metadata": {},
"name": "ConsoleLayoutBreadcrumbUpdateFrameworkQuery",
"operationKind": "query",
"text": "query ConsoleLayoutBreadcrumbUpdateFrameworkQuery(\n $frameworkId: ID!\n) {\n framework: node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n }\n }\n}\n"
}
};
})();
(node as any).hash = "c6381fa068566e0ec00356b3e6835700";
export default node;

View File

@@ -87,14 +87,23 @@ function FrameworkOverviewPageContent({
<h2 className="text-2xl font-semibold mb-1">{framework.name}</h2>
<p className="text-muted-foreground">{framework.description}</p>
</div>
<Button asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/create`}
>
<Plus className="mr-2 h-4 w-4" />
Create Control
</Link>
</Button>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/update`}
>
Edit Framework
</Link>
</Button>
<Button asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/create`}
>
<Plus className="mr-2 h-4 w-4" />
Create Control
</Link>
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,268 @@
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 { UpdateFrameworkPageUpdateFrameworkMutation } from "./__generated__/UpdateFrameworkPageUpdateFrameworkMutation.graphql";
import { UpdateFrameworkPageQuery as UpdateFrameworkPageQueryType } from "./__generated__/UpdateFrameworkPageQuery.graphql";
const updateFrameworkMutation = graphql`
mutation UpdateFrameworkPageUpdateFrameworkMutation(
$input: UpdateFrameworkInput!
) {
updateFramework(input: $input) {
id
name
description
version
}
}
`;
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 [editedFields, 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 hasChanges = editedFields.size > 0;
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 (
<>
<Helmet>
<title>Update Framework - Probo</title>
</Helmet>
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold">Update Framework</h1>
<p className="text-muted-foreground">Update the framework 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 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>
</div>
</>
);
}
function UpdateFrameworkPageFallback() {
return <div>Loading...</div>;
}
export default function UpdateFrameworkPage() {
const { frameworkId } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<UpdateFrameworkPageQueryType>(updateFrameworkQuery);
useEffect(() => {
if (frameworkId) {
loadQuery({ frameworkId });
}
}, [frameworkId, loadQuery]);
if (!queryRef) {
return <UpdateFrameworkPageFallback />;
}
return (
<Suspense fallback={<UpdateFrameworkPageFallback />}>
<UpdateFrameworkPageContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,154 @@
/**
* @generated SignedSource<<c4c46870b65e75baab882f1e9689c119>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdateFrameworkPageQuery$variables = {
frameworkId: string;
};
export type UpdateFrameworkPageQuery$data = {
readonly node: {
readonly description?: string;
readonly id?: string;
readonly name?: string;
readonly version?: number;
};
};
export type UpdateFrameworkPageQuery = {
response: UpdateFrameworkPageQuery$data;
variables: UpdateFrameworkPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "frameworkId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "frameworkId"
}
],
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": "version",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateFrameworkPageQuery",
"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*/)
],
"type": "Framework",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateFrameworkPageQuery",
"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*/)
],
"type": "Framework",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "cae85e293ba2bd3d1d0f13acd1e777f5",
"id": null,
"metadata": {},
"name": "UpdateFrameworkPageQuery",
"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"
}
};
})();
(node as any).hash = "56744b0fcc4892c88f70c71b0ca49b90";
export default node;

View File

@@ -0,0 +1,119 @@
/**
* @generated SignedSource<<190ec3d14b98c4685fcc363fc41cee96>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdateFrameworkInput = {
description?: string | null | undefined;
expectedVersion: number;
id: string;
name?: string | null | undefined;
};
export type UpdateFrameworkPageUpdateFrameworkMutation$variables = {
input: UpdateFrameworkInput;
};
export type UpdateFrameworkPageUpdateFrameworkMutation$data = {
readonly updateFramework: {
readonly description: string;
readonly id: string;
readonly name: string;
readonly version: number;
};
};
export type UpdateFrameworkPageUpdateFrameworkMutation = {
response: UpdateFrameworkPageUpdateFrameworkMutation$data;
variables: UpdateFrameworkPageUpdateFrameworkMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "Framework",
"kind": "LinkedField",
"name": "updateFramework",
"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": "version",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateFrameworkPageUpdateFrameworkMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateFrameworkPageUpdateFrameworkMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "f0f708c6034a6f3c27fbad0193535e51",
"id": null,
"metadata": {},
"name": "UpdateFrameworkPageUpdateFrameworkMutation",
"operationKind": "mutation",
"text": "mutation UpdateFrameworkPageUpdateFrameworkMutation(\n $input: UpdateFrameworkInput!\n) {\n updateFramework(input: $input) {\n id\n name\n description\n version\n }\n}\n"
}
};
})();
(node as any).hash = "b8a8cebfad0ef454a5d5436b61cc595c";
export default node;

View File

@@ -184,6 +184,8 @@ type FrameworkEdge {
type Framework implements Node {
id: ID!
version: Int!
name: String!
description: String!
@@ -396,6 +398,7 @@ type Mutation {
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
createFramework(input: CreateFrameworkInput!): CreateFrameworkPayload!
createControl(input: CreateControlInput!): CreateControlPayload!
updateFramework(input: UpdateFrameworkInput!): Framework!
}
input CreateVendorInput {
@@ -549,6 +552,13 @@ input CreateFrameworkInput {
description: String!
}
input UpdateFrameworkInput {
id: ID!
expectedVersion: Int!
name: String
description: String
}
type CreateFrameworkPayload {
frameworkEdge: FrameworkEdge!
}

View File

@@ -184,6 +184,7 @@ type ComplexityRoot struct {
ID func(childComplexity int) int
Name func(childComplexity int) int
UpdatedAt func(childComplexity int) int
Version func(childComplexity int) int
}
FrameworkConnection struct {
@@ -207,6 +208,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
UpdateFramework func(childComplexity int, input types.UpdateFrameworkInput) int
UpdatePeople func(childComplexity int, input types.UpdatePeopleInput) int
UpdateTaskState func(childComplexity int, input types.UpdateTaskStateInput) int
UpdateVendor func(childComplexity int, input types.UpdateVendorInput) int
@@ -375,6 +377,7 @@ type MutationResolver interface {
DeleteTask(ctx context.Context, input types.DeleteTaskInput) (*types.DeleteTaskPayload, error)
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.Framework, error)
}
type OrganizationResolver interface {
Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error)
@@ -859,6 +862,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Framework.UpdatedAt(childComplexity), true
case "Framework.version":
if e.complexity.Framework.Version == nil {
break
}
return e.complexity.Framework.Version(childComplexity), true
case "FrameworkConnection.edges":
if e.complexity.FrameworkConnection.Edges == nil {
break
@@ -1007,6 +1017,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.DeleteVendor(childComplexity, args["input"].(types.DeleteVendorInput)), true
case "Mutation.updateFramework":
if e.complexity.Mutation.UpdateFramework == nil {
break
}
args, err := ec.field_Mutation_updateFramework_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.UpdateFramework(childComplexity, args["input"].(types.UpdateFrameworkInput)), true
case "Mutation.updatePeople":
if e.complexity.Mutation.UpdatePeople == nil {
break
@@ -1642,6 +1664,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec.unmarshalInputDeletePeopleInput,
ec.unmarshalInputDeleteTaskInput,
ec.unmarshalInputDeleteVendorInput,
ec.unmarshalInputUpdateFrameworkInput,
ec.unmarshalInputUpdatePeopleInput,
ec.unmarshalInputUpdateTaskStateInput,
ec.unmarshalInputUpdateVendorInput,
@@ -1928,6 +1951,8 @@ type FrameworkEdge {
type Framework implements Node {
id: ID!
version: Int!
name: String!
description: String!
@@ -2140,6 +2165,7 @@ type Mutation {
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
createFramework(input: CreateFrameworkInput!): CreateFrameworkPayload!
createControl(input: CreateControlInput!): CreateControlPayload!
updateFramework(input: UpdateFrameworkInput!): Framework!
}
input CreateVendorInput {
@@ -2293,6 +2319,13 @@ input CreateFrameworkInput {
description: String!
}
input UpdateFrameworkInput {
id: ID!
expectedVersion: Int!
name: String
description: String
}
type CreateFrameworkPayload {
frameworkEdge: FrameworkEdge!
}
@@ -2853,6 +2886,29 @@ func (ec *executionContext) field_Mutation_deleteVendor_argsInput(
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{}
arg0, err := ec.field_Mutation_updateFramework_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_updateFramework_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.UpdateFrameworkInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNUpdateFrameworkInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateFrameworkInput(ctx, tmp)
}
var zeroVal types.UpdateFrameworkInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_updatePeople_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -5799,6 +5855,44 @@ func (ec *executionContext) fieldContext_Framework_id(_ context.Context, field g
return fc, nil
}
func (ec *executionContext) _Framework_version(ctx context.Context, field graphql.CollectedField, obj *types.Framework) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Framework_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_Framework_version(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Framework",
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) _Framework_name(ctx context.Context, field graphql.CollectedField, obj *types.Framework) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Framework_name(ctx, field)
if err != nil {
@@ -6165,6 +6259,8 @@ func (ec *executionContext) fieldContext_FrameworkEdge_node(_ context.Context, f
switch field.Name {
case "id":
return ec.fieldContext_Framework_id(ctx, field)
case "version":
return ec.fieldContext_Framework_version(ctx, field)
case "name":
return ec.fieldContext_Framework_name(ctx, field)
case "description":
@@ -6831,6 +6927,65 @@ func (ec *executionContext) fieldContext_Mutation_createControl(ctx context.Cont
return fc, nil
}
func (ec *executionContext) _Mutation_updateFramework(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_updateFramework(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().UpdateFramework(rctx, fc.Args["input"].(types.UpdateFrameworkInput))
})
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.Framework)
fc.Result = res
return ec.marshalNFramework2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐFramework(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_updateFramework(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 "id":
return ec.fieldContext_Framework_id(ctx, field)
case "version":
return ec.fieldContext_Framework_version(ctx, field)
case "name":
return ec.fieldContext_Framework_name(ctx, field)
case "description":
return ec.fieldContext_Framework_description(ctx, field)
case "controls":
return ec.fieldContext_Framework_controls(ctx, field)
case "createdAt":
return ec.fieldContext_Framework_createdAt(ctx, field)
case "updatedAt":
return ec.fieldContext_Framework_updatedAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type Framework", field.Name)
},
}
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_updateFramework_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 {
@@ -12122,6 +12277,54 @@ func (ec *executionContext) unmarshalInputDeleteVendorInput(ctx context.Context,
return it, nil
}
func (ec *executionContext) unmarshalInputUpdateFrameworkInput(ctx context.Context, obj any) (types.UpdateFrameworkInput, error) {
var it types.UpdateFrameworkInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"id", "expectedVersion", "name", "description"}
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
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputUpdatePeopleInput(ctx context.Context, obj any) (types.UpdatePeopleInput, error) {
var it types.UpdatePeopleInput
asMap := map[string]any{}
@@ -13493,6 +13696,11 @@ func (ec *executionContext) _Framework(ctx context.Context, sel ast.SelectionSet
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "version":
out.Values[i] = ec._Framework_version(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "name":
out.Values[i] = ec._Framework_name(ctx, field, obj)
if out.Values[i] == graphql.Null {
@@ -13765,6 +13973,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "updateFramework":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_updateFramework(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -15939,6 +16154,10 @@ func (ec *executionContext) marshalNEvidenceStateTransitionEdge2ᚖgithubᚗcom
return ec._EvidenceStateTransitionEdge(ctx, sel, v)
}
func (ec *executionContext) marshalNFramework2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐFramework(ctx context.Context, sel ast.SelectionSet, v types.Framework) graphql.Marshaler {
return ec._Framework(ctx, sel, &v)
}
func (ec *executionContext) marshalNFramework2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐFramework(ctx context.Context, sel ast.SelectionSet, v *types.Framework) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
@@ -16512,6 +16731,11 @@ func (ec *executionContext) marshalNTaskStateTransitionEdge2ᚖgithubᚗcomᚋge
return ec._TaskStateTransitionEdge(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)
}
func (ec *executionContext) unmarshalNUpdatePeopleInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdatePeopleInput(ctx context.Context, v any) (types.UpdatePeopleInput, error) {
res, err := ec.unmarshalInputUpdatePeopleInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)

View File

@@ -42,6 +42,7 @@ func NewFrameworkEdge(f *coredata.Framework) *FrameworkEdge {
func NewFramework(f *coredata.Framework) *Framework {
return &Framework{
ID: f.ID,
Version: f.Version,
Name: f.Name,
Description: f.Description,
CreatedAt: f.CreatedAt,

View File

@@ -204,6 +204,7 @@ type EvidenceStateTransitionEdge struct {
type Framework struct {
ID gid.GID `json:"id"`
Version int `json:"version"`
Name string `json:"name"`
Description string `json:"description"`
Controls *ControlConnection `json:"controls"`
@@ -333,6 +334,13 @@ type TaskStateTransitionEdge struct {
Node *TaskStateTransition `json:"node"`
}
type UpdateFrameworkInput struct {
ID gid.GID `json:"id"`
ExpectedVersion int `json:"expectedVersion"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdatePeopleInput struct {
ID gid.GID `json:"id"`
ExpectedVersion int `json:"expectedVersion"`

View File

@@ -270,6 +270,21 @@ func (r *mutationResolver) CreateControl(ctx context.Context, input types.Create
}, nil
}
// UpdateFramework is the resolver for the updateFramework field.
func (r *mutationResolver) UpdateFramework(ctx context.Context, input types.UpdateFrameworkInput) (*types.Framework, error) {
framework, err := r.proboSvc.UpdateFramework(ctx, probo.UpdateFrameworkRequest{
ID: input.ID,
ExpectedVersion: input.ExpectedVersion,
Name: input.Name,
Description: input.Description,
})
if err != nil {
return nil, fmt.Errorf("cannot update framework: %w", err)
}
return types.NewFramework(framework), 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"
@@ -35,9 +36,16 @@ type (
ContentRef string
CreatedAt time.Time
UpdatedAt time.Time
Version int
}
Frameworks []*Framework
UpdateFrameworkParams struct {
ExpectedVersion int
Name *string
Description *string
}
)
func (f Framework) CursorKey() page.CursorKey {
@@ -53,8 +61,10 @@ func (f *Framework) scan(r pgx.Row) error {
&f.ContentRef,
&f.CreatedAt,
&f.UpdatedAt,
&f.Version,
)
}
func (f *Frameworks) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
@@ -70,7 +80,8 @@ SELECT
description,
content_ref,
created_at,
updated_at
updated_at,
version
FROM
frameworks
WHERE
@@ -124,7 +135,8 @@ SELECT
description,
content_ref,
created_at,
updated_at
updated_at,
version
FROM
frameworks
WHERE
@@ -162,7 +174,8 @@ INSERT INTO
description,
content_ref,
created_at,
updated_at
updated_at,
version
)
VALUES (
@framework_id,
@@ -171,7 +184,8 @@ VALUES (
@description,
@content_ref,
@created_at,
@updated_at
@updated_at,
@version
);
`
@@ -183,6 +197,7 @@ VALUES (
"content_ref": f.ContentRef,
"created_at": f.CreatedAt,
"updated_at": f.UpdatedAt,
"version": f.Version,
}
_, err := conn.Exec(ctx, q, args)
return err
@@ -209,3 +224,59 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (f *Framework) Update(
ctx context.Context,
conn pg.Conn,
scope *Scope,
params UpdateFrameworkParams,
) error {
q := `
UPDATE frameworks SET
name = COALESCE(@name, name),
description = COALESCE(@description, description),
updated_at = @updated_at,
version = version + 1
WHERE %s
AND id = @framework_id
AND version = @expected_version
RETURNING
id,
organization_id,
name,
description,
content_ref,
created_at,
updated_at,
version
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"framework_id": f.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
}
maps.Copy(args, scope.SQLArguments())
r := conn.QueryRow(ctx, q, args)
f2 := Framework{}
if err := f2.scan(r); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrConcurrentModification
}
return err
}
*f = f2
return nil
}

View File

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

View File

@@ -0,0 +1,40 @@
package probo
import (
"context"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo/coredata"
"go.gearno.de/kit/pg"
)
type UpdateFrameworkRequest struct {
ID gid.GID
ExpectedVersion int
Name *string
Description *string
}
func (s Service) UpdateFramework(
ctx context.Context,
req UpdateFrameworkRequest,
) (*coredata.Framework, error) {
params := coredata.UpdateFrameworkParams{
ExpectedVersion: req.ExpectedVersion,
Name: req.Name,
Description: req.Description,
}
framework := &coredata.Framework{ID: req.ID}
err := s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
return framework.Update(ctx, conn, s.scope, params)
})
if err != nil {
return nil, err
}
return framework, nil
}