Add frameworks actions

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-06-02 16:41:20 +02:00
committed by Sacha Al Himdani
parent 74c136f6c0
commit 7a65ec8672
7 changed files with 482 additions and 52 deletions

View File

@@ -5,6 +5,8 @@ import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui"; import { useConfirm } from "@probo/ui";
export const connectionListKey = "FrameworksListQuery_frameworks";
export const frameworksQuery = graphql` export const frameworksQuery = graphql`
query FrameworkGraphListQuery($organizationId: ID!) { query FrameworkGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) { organization: node(id: $organizationId) {
@@ -47,8 +49,9 @@ export const useDeleteFrameworkMutation = (
const confirm = useConfirm(); const confirm = useConfirm();
const { __ } = useTranslate(); const { __ } = useTranslate();
return useCallback(() => { return useCallback(
confirm( (options?: { onSuccess?: () => void }) => {
return confirm(
() => { () => {
return commitDelete({ return commitDelete({
variables: { variables: {
@@ -57,6 +60,7 @@ export const useDeleteFrameworkMutation = (
}, },
connections: [connectionId], connections: [connectionId],
}, },
...options,
}); });
}, },
{ {
@@ -68,7 +72,9 @@ export const useDeleteFrameworkMutation = (
), ),
} }
); );
}, [framework, connectionId, commitDelete]); },
[framework, connectionId, commitDelete]
);
}; };
export const frameworkNodeQuery = graphql` export const frameworkNodeQuery = graphql`

View File

@@ -28,7 +28,7 @@ export function useMutationWithToasts<T extends MutationParameters>(
const options = { ...baseOptions, ...queryOptions }; const options = { ...baseOptions, ...queryOptions };
return new Promise<void>((resolve, reject) => return new Promise<void>((resolve, reject) =>
mutate({ mutate({
...options, ...queryOptions,
onCompleted: (response, error) => { onCompleted: (response, error) => {
options.onCompleted?.(response, error); options.onCompleted?.(response, error);
if (error) { if (error) {

View File

@@ -5,17 +5,30 @@ import {
type PreloadedQuery, type PreloadedQuery,
} from "react-relay"; } from "react-relay";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { graphql } from "relay-runtime"; import { ConnectionHandler, graphql } from "relay-runtime";
import { ControlItem, PageHeader } from "@probo/ui"; import {
ActionDropdown,
Button,
ControlItem,
DropdownItem,
IconPencil,
IconTrashCan,
PageHeader,
} from "@probo/ui";
import { FrameworkLogo } from "/components/FrameworkLogo"; import { FrameworkLogo } from "/components/FrameworkLogo";
import { frameworkNodeQuery } from "/hooks/graph/FrameworkGraph"; import {
connectionListKey,
frameworkNodeQuery,
useDeleteFrameworkMutation,
} from "/hooks/graph/FrameworkGraph";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { LinkedMeasuresCard } from "/components/measures/LinkedMeasuresCard"; import { LinkedMeasuresCard } from "/components/measures/LinkedMeasuresCard";
import { useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import type { FrameworkGraphNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql"; import type { FrameworkGraphNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql";
import type { FrameworkDetailPageFragment$key } from "./__generated__/FrameworkDetailPageFragment.graphql"; import type { FrameworkDetailPageFragment$key } from "./__generated__/FrameworkDetailPageFragment.graphql";
import { LinkedDocumentsCard } from "/components/documents/LinkedDocumentsCard"; import { LinkedDocumentsCard } from "/components/documents/LinkedDocumentsCard";
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
const frameworkDetailFragment = graphql` const frameworkDetailFragment = graphql`
fragment FrameworkDetailPageFragment on Framework { fragment FrameworkDetailPageFragment on Framework {
@@ -122,7 +135,14 @@ export default function FrameworkDetailPage(props: Props) {
frameworkDetailFragment, frameworkDetailFragment,
data.node data.node
); );
const navigate = useNavigate();
const controls = framework.controls.edges.map((edge) => edge.node);
const selectedControl = controlId
? controls.find((control) => control.id === controlId)
: controls[0];
usePageTitle(`${framework.name} | ${selectedControl?.referenceId}`);
// Mutations
const [detachMeasure, isDetachingMeasure] = useMutation( const [detachMeasure, isDetachingMeasure] = useMutation(
detachMeasureMutation detachMeasureMutation
); );
@@ -135,13 +155,18 @@ export default function FrameworkDetailPage(props: Props) {
const [attachDocument, isAttachingDocument] = useMutation( const [attachDocument, isAttachingDocument] = useMutation(
attachDocumentMutation attachDocumentMutation
); );
const deleteFramework = useDeleteFrameworkMutation(
framework,
ConnectionHandler.getConnectionID(organizationId, connectionListKey)!
);
const controls = framework.controls.edges.map((edge) => edge.node); const onDelete = () => {
deleteFramework({
const selectedControl = controlId onSuccess: () => {
? controls.find((control) => control.id === controlId) navigate(`/organizations/${organizationId}/frameworks`);
: controls[0]; },
usePageTitle(`${framework.name} | ${selectedControl?.referenceId}`); });
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -152,7 +177,21 @@ export default function FrameworkDetailPage(props: Props) {
{framework.name} {framework.name}
</> </>
} }
/> >
<FrameworkFormDialog
organizationId={organizationId}
framework={framework}
>
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
</FrameworkFormDialog>
<ActionDropdown variant="secondary">
<DropdownItem icon={IconTrashCan} variant="danger" onClick={onDelete}>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</PageHeader>
<div className="text-lg font-semibold"> <div className="text-lg font-semibold">
{__("Requirement categories")} {__("Requirement categories")}
</div> </div>

View File

@@ -6,6 +6,7 @@ import {
DropdownItem, DropdownItem,
FileButton, FileButton,
FrameworkSelector, FrameworkSelector,
IconPencil,
IconTrashCan, IconTrashCan,
IconUpload, IconUpload,
PageHeader, PageHeader,
@@ -26,8 +27,8 @@ import { Link } from "react-router";
import type { FrameworksPageCardFragment$key } from "./__generated__/FrameworksPageCardFragment.graphql"; import type { FrameworksPageCardFragment$key } from "./__generated__/FrameworksPageCardFragment.graphql";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useState, type ChangeEventHandler } from "react"; import { useState, type ChangeEventHandler } from "react";
import { CreateFrameworkDialog } from "./dialogs/CreateFrameworkDialog";
import { FrameworkLogo } from "/components/FrameworkLogo"; import { FrameworkLogo } from "/components/FrameworkLogo";
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
type Props = { type Props = {
queryRef: PreloadedQuery<FrameworkGraphListQuery>; queryRef: PreloadedQuery<FrameworkGraphListQuery>;
@@ -119,7 +120,7 @@ export default function FrameworksPage(props: Props) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<CreateFrameworkDialog <FrameworkFormDialog
ref={dialogRef} ref={dialogRef}
connectionId={connectionId} connectionId={connectionId}
organizationId={data.organization.id!} organizationId={data.organization.id!}
@@ -177,14 +178,29 @@ function FrameworkCard(props: FrameworkCardProps) {
props.connectionId props.connectionId
); );
const { __ } = useTranslate(); const { __ } = useTranslate();
const dialogRef = useDialogRef();
return ( return (
<Card padded className="p-6 bg-white rounded shadow relative"> <Card padded className="p-6 bg-white rounded shadow relative">
<FrameworkFormDialog
ref={dialogRef}
connectionId={props.connectionId}
organizationId={props.organizationId}
framework={framework}
/>
<div className="flex justify-between mb-3"> <div className="flex justify-between mb-3">
<FrameworkLogo {...framework} /> <FrameworkLogo {...framework} />
<ActionDropdown className="z-10 relative"> <ActionDropdown className="z-10 relative">
<DropdownItem
icon={IconPencil}
onClick={() => {
dialogRef.current?.open();
}}
>
{__("Edit")}
</DropdownItem>
<DropdownItem <DropdownItem
icon={IconTrashCan} icon={IconTrashCan}
onClick={deleteFramework} onClick={() => deleteFramework()}
variant="danger" variant="danger"
> >
{__("Delete")} {__("Delete")}

View File

@@ -9,13 +9,14 @@ import {
DialogFooter, DialogFooter,
Input, Input,
Textarea, Textarea,
useDialogRef,
type DialogRef, type DialogRef,
} from "@probo/ui"; } from "@probo/ui";
import { z } from "zod"; import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema"; import { useFormWithSchema } from "/hooks/useFormWithSchema";
const createFrameworkMutation = graphql` const createFrameworkMutation = graphql`
mutation CreateFrameworkDialogMutation( mutation FrameworkFormDialogMutation(
$input: CreateFrameworkInput! $input: CreateFrameworkInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -30,10 +31,28 @@ const createFrameworkMutation = graphql`
} }
`; `;
const updateFrameworkMutation = graphql`
mutation FrameworkFormDialogUpdateMutation($input: UpdateFrameworkInput!) {
updateFramework(input: $input) {
framework {
id
name
description
}
}
}
`;
type Props = { type Props = {
connectionId: string; connectionId?: string;
organizationId: string; organizationId: string;
ref: DialogRef; framework?: {
id: string;
name: string;
description: string;
};
ref?: DialogRef;
children?: React.ReactNode;
}; };
const schema = z.object({ const schema = z.object({
@@ -41,18 +60,41 @@ const schema = z.object({
description: z.string().max(255).optional(), description: z.string().max(255).optional(),
}); });
export function CreateFrameworkDialog(props: Props) { /**
* Form to update or create a new framework
*/
export function FrameworkFormDialog(props: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const { register, handleSubmit, reset } = useFormWithSchema(schema, {}); const dialogRef = props.ref ?? useDialogRef();
const [commitCreate, isCreating] = useMutationWithToasts( const { register, handleSubmit, reset } = useFormWithSchema(schema, {
createFrameworkMutation, defaultValues: {
{ name: props.framework?.name ?? "",
description: props.framework?.description ?? "",
},
});
const [create, isCreating] = useMutationWithToasts(createFrameworkMutation, {
successMessage: __("Framework created successfully"), successMessage: __("Framework created successfully"),
errorMessage: __("Failed to create framework"), errorMessage: __("Failed to create framework"),
} });
); const [update, isUpdating] = useMutationWithToasts(updateFrameworkMutation, {
successMessage: __("Framework updated successfully"),
errorMessage: __("Failed to update framework"),
});
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
await commitCreate({ if (props.framework) {
await update({
variables: {
input: {
id: props.framework.id,
...data,
},
},
});
reset(data);
dialogRef.current?.close();
return;
}
await create({
variables: { variables: {
input: { input: {
...data, ...data,
@@ -62,12 +104,13 @@ export function CreateFrameworkDialog(props: Props) {
}, },
}); });
reset(); reset();
props.ref.current?.close(); dialogRef.current?.close();
}); });
return ( return (
<Dialog <Dialog
ref={props.ref} trigger={props.children}
ref={dialogRef}
title={<Breadcrumb items={[__("Framework"), __("New Framework")]} />} title={<Breadcrumb items={[__("Framework"), __("New Framework")]} />}
> >
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
@@ -86,8 +129,8 @@ export function CreateFrameworkDialog(props: Props) {
/> />
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={isCreating}> <Button type="submit" disabled={isCreating || isUpdating}>
{__("Create framework")} {props.framework ? __("Update framework") : __("Create framework")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -0,0 +1,203 @@
/**
* @generated SignedSource<<fef15c2f99a731cf6c49a99487e37c59>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CreateFrameworkInput = {
description: string;
name: string;
organizationId: string;
};
export type FrameworkFormDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateFrameworkInput;
};
export type FrameworkFormDialogMutation$data = {
readonly createFramework: {
readonly frameworkEdge: {
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"FrameworksPageCardFragment">;
};
};
};
};
export type FrameworkFormDialogMutation = {
response: FrameworkFormDialogMutation$data;
variables: FrameworkFormDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "FrameworkFormDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateFrameworkPayload",
"kind": "LinkedField",
"name": "createFramework",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "frameworkEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "FrameworksPageCardFragment"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "FrameworkFormDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateFrameworkPayload",
"kind": "LinkedField",
"name": "createFramework",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "FrameworkEdge",
"kind": "LinkedField",
"name": "frameworkEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "frameworkEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "931895b4bbb7caacd8ee0ee69de76cd6",
"id": null,
"metadata": {},
"name": "FrameworkFormDialogMutation",
"operationKind": "mutation",
"text": "mutation FrameworkFormDialogMutation(\n $input: CreateFrameworkInput!\n) {\n createFramework(input: $input) {\n frameworkEdge {\n node {\n id\n ...FrameworksPageCardFragment\n }\n }\n }\n}\n\nfragment FrameworksPageCardFragment on Framework {\n id\n name\n description\n}\n"
}
};
})();
(node as any).hash = "efed7b4ef49eea43e0bf9c0a8839c4ee";
export default node;

View File

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