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 { useConfirm } from "@probo/ui";
export const connectionListKey = "FrameworksListQuery_frameworks";
export const frameworksQuery = graphql`
query FrameworkGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
@@ -47,28 +49,32 @@ export const useDeleteFrameworkMutation = (
const confirm = useConfirm();
const { __ } = useTranslate();
return useCallback(() => {
confirm(
() => {
return commitDelete({
variables: {
input: {
frameworkId: framework.id!,
return useCallback(
(options?: { onSuccess?: () => void }) => {
return confirm(
() => {
return commitDelete({
variables: {
input: {
frameworkId: framework.id!,
},
connections: [connectionId],
},
connections: [connectionId],
},
});
},
{
message: sprintf(
__(
'This will permanently delete framework "%s". This action cannot be undone.'
...options,
});
},
{
message: sprintf(
__(
'This will permanently delete framework "%s". This action cannot be undone.'
),
framework.name
),
framework.name
),
}
);
}, [framework, connectionId, commitDelete]);
}
);
},
[framework, connectionId, commitDelete]
);
};
export const frameworkNodeQuery = graphql`

View File

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

View File

@@ -5,17 +5,30 @@ import {
type PreloadedQuery,
} from "react-relay";
import { usePageTitle } from "@probo/hooks";
import { graphql } from "relay-runtime";
import { ControlItem, PageHeader } from "@probo/ui";
import { ConnectionHandler, graphql } from "relay-runtime";
import {
ActionDropdown,
Button,
ControlItem,
DropdownItem,
IconPencil,
IconTrashCan,
PageHeader,
} from "@probo/ui";
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 { LinkedMeasuresCard } from "/components/measures/LinkedMeasuresCard";
import { useParams } from "react-router";
import { useNavigate, useParams } from "react-router";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { FrameworkGraphNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql";
import type { FrameworkDetailPageFragment$key } from "./__generated__/FrameworkDetailPageFragment.graphql";
import { LinkedDocumentsCard } from "/components/documents/LinkedDocumentsCard";
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
const frameworkDetailFragment = graphql`
fragment FrameworkDetailPageFragment on Framework {
@@ -122,7 +135,14 @@ export default function FrameworkDetailPage(props: Props) {
frameworkDetailFragment,
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(
detachMeasureMutation
);
@@ -135,13 +155,18 @@ export default function FrameworkDetailPage(props: Props) {
const [attachDocument, isAttachingDocument] = useMutation(
attachDocumentMutation
);
const deleteFramework = useDeleteFrameworkMutation(
framework,
ConnectionHandler.getConnectionID(organizationId, connectionListKey)!
);
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}`);
const onDelete = () => {
deleteFramework({
onSuccess: () => {
navigate(`/organizations/${organizationId}/frameworks`);
},
});
};
return (
<div className="space-y-6">
@@ -152,7 +177,21 @@ export default function FrameworkDetailPage(props: Props) {
{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">
{__("Requirement categories")}
</div>

View File

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

View File

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