Add policies version history

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-27 10:12:56 +02:00
committed by Sacha Al Himdani
parent abe0ce1dde
commit 2e6741942b
36 changed files with 1710 additions and 883 deletions

View File

@@ -20,7 +20,6 @@ export function ControlledField({
name={name}
render={({ field }) => (
<>
{/* @ts-expect-error field is too dynamic */}
<Field
{...props}
{...field}

View File

@@ -12,6 +12,8 @@ export const policiesQuery = graphql`
}
`;
export const PoliciesConnectionKey = "PoliciesPageFragment_policies";
const deletePolicyMutation = graphql`
mutation PolicyGraphDeleteMutation(
$input: DeletePolicyInput!

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<88a40781a78a282ebde97d4cb73bb996>>
* @generated SignedSource<<bfeead50dec41ac2f5fac7c6d70f47fb>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -39,12 +39,33 @@ v1 = [
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 20
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
@@ -93,14 +114,8 @@ return {
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/),
{
"kind": "InlineFragment",
"selections": [
@@ -119,26 +134,14 @@ return {
"name": "owner",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
(v3/*: any*/),
(v4/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"args": (v5/*: any*/),
"concreteType": "PolicyVersionConnection",
"kind": "LinkedField",
"name": "versions",
@@ -160,7 +163,7 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": null,
@@ -226,7 +229,7 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": null,
@@ -242,15 +245,89 @@ return {
}
],
"storageKey": "signatures(first:100)"
}
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "changelog",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "publishedBy",
"plural": false,
"selections": [
(v4/*: any*/),
(v3/*: any*/)
],
"storageKey": null
},
(v2/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": "versions(first:1)"
"storageKey": "versions(first:20)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "PolicyPage_versions",
"kind": "LinkedHandle",
"name": "versions"
}
],
"type": "Policy",
@@ -262,12 +339,12 @@ return {
]
},
"params": {
"cacheID": "061b3441def7dfcf73881e57077e1738",
"cacheID": "e3a62b161b9fce47237c98f90a5d6d2e",
"id": null,
"metadata": {},
"name": "PolicyGraphNodeQuery",
"operationKind": "query",
"text": "query PolicyGraphNodeQuery(\n $policyId: ID!\n) {\n node(id: $policyId) {\n __typename\n ... on Policy {\n ...PolicyPagePolicyFragment\n }\n id\n }\n}\n\nfragment PolicyPagePolicyFragment on Policy {\n id\n title\n owner {\n id\n fullName\n }\n versions(first: 1) {\n edges {\n node {\n id\n content\n status\n publishedAt\n version\n updatedAt\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n }\n }\n }\n}\n"
"text": "query PolicyGraphNodeQuery(\n $policyId: ID!\n) {\n node(id: $policyId) {\n __typename\n ... on Policy {\n ...PolicyPagePolicyFragment\n }\n id\n }\n}\n\nfragment PolicyPagePolicyFragment on Policy {\n id\n title\n owner {\n id\n fullName\n }\n versions(first: 20) {\n edges {\n node {\n id\n content\n status\n publishedAt\n version\n updatedAt\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n ...PolicyVersionHistoryDialogFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment PolicyVersionHistoryDialogFragment on PolicyVersion {\n id\n version\n status\n content\n changelog\n publishedAt\n updatedAt\n publishedBy {\n fullName\n id\n }\n}\n"
}
};
})();

View File

@@ -28,7 +28,8 @@ export function useMutationWithToasts<T extends MutationParameters>(
const options = { ...baseOptions, ...queryOptions };
mutate({
...options,
onCompleted: (_, error) => {
onCompleted: (response, error) => {
options.onCompleted?.(response, error);
if (error) {
toast({
title: __("Error"),

View File

@@ -27,6 +27,7 @@ import {
Th,
Thead,
Tr,
useConfirmDialogRef,
} from "@probo/ui";
import {
measuresQuery,
@@ -156,6 +157,7 @@ export default function MeasuresPage(props: Props) {
key={category}
category={category}
measures={measuresPerCategory[category]}
connectionId={connectionId}
/>
))}
</div>
@@ -165,6 +167,7 @@ export default function MeasuresPage(props: Props) {
type CategoryProps = {
category: string;
measures: NodeOf<MeasuresPageFragment$data["measures"]>[];
connectionId: string;
};
function Category(props: CategoryProps) {
@@ -210,7 +213,11 @@ function Category(props: CategoryProps) {
</Thead>
<Tbody>
{measures.map((measure) => (
<MeasureRow key={measure.id} measure={measure} />
<MeasureRow
key={measure.id}
measure={measure}
connectionId={props.connectionId}
/>
))}
</Tbody>
</Table>
@@ -232,6 +239,7 @@ function Category(props: CategoryProps) {
type MeasureRowProps = {
measure: NodeOf<MeasuresPageFragment$data["measures"]>;
connectionId: string;
};
function MeasureRow(props: MeasureRowProps) {
@@ -239,42 +247,50 @@ function MeasureRow(props: MeasureRowProps) {
const [deleteMeasure, isDeleting] = useDeleteMeasureMutation();
const onDelete = () => {
deleteMeasure({
variables: {
input: { measureId: props.measure.id },
connections: ["MeasuresListQuery_measures"],
},
return new Promise<void>((resolve) => {
deleteMeasure({
variables: {
input: { measureId: props.measure.id },
connections: [props.connectionId],
},
onCompleted: () => resolve(),
});
});
};
const confirmRef = useConfirmDialogRef();
return (
<Tr>
<Td>{props.measure.name}</Td>
<Td width={120}>
<MeasureBadge state={props.measure.state} />
</Td>
<Td width={50} className="text-end">
<ActionDropdown>
<DropdownItem icon={IconPencil}>{__("Edit")}</DropdownItem>
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the measure "%s". This action cannot be undone.'
),
props.measure.name
)}
onConfirm={() => onDelete()}
>
<>
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the measure "%s". This action cannot be undone.'
),
props.measure.name
)}
onConfirm={onDelete}
ref={confirmRef}
/>
<Tr>
<Td>{props.measure.name}</Td>
<Td width={120}>
<MeasureBadge state={props.measure.state} />
</Td>
<Td width={50} className="text-end">
<ActionDropdown>
<DropdownItem icon={IconPencil}>{__("Edit")}</DropdownItem>
<DropdownItem
onClick={() => confirmRef.current?.open()}
disabled={isDeleting}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ConfirmDialog>
</ActionDropdown>
</Td>
</Tr>
</ActionDropdown>
</Td>
</Tr>
</>
);
}

View File

@@ -9,9 +9,6 @@ import {
Td,
Avatar,
Badge,
ActionDropdown,
DropdownItem,
IconPencil,
ConfirmDialog,
IconTrashCan,
Button,
@@ -31,7 +28,7 @@ import {
import type { PoliciesPageListFragment$key } from "./__generated__/PoliciesPageListFragment.graphql";
import { usePageTitle } from "@probo/hooks";
import { sprintf } from "@probo/helpers";
import FormPolicyDialog from "./FormPolicyDialog";
import { CreatePolicyDialog } from "./dialogs/CreatePolicyDialog";
import type { PoliciesPageRowFragment$key } from "./__generated__/PoliciesPageRowFragment.graphql";
const policiesFragment = graphql`
@@ -80,7 +77,7 @@ export default function PoliciesPage(props: Props) {
title={__("Policies")}
description={__("Manage your organization’s policies")}
>
<FormPolicyDialog
<CreatePolicyDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New policy")}</Button>}
/>
@@ -160,45 +157,47 @@ function PolicyRow({
const signedCount = signatures.filter(
(signature) => signature.state === "SIGNED"
).length;
const [deletePolicy, isDeleting] = useDeletePolicyMutation();
const [deletePolicy] = useDeletePolicyMutation();
const handleDelete = () => {
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
return new Promise<void>((resolve) => {
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
});
};
return (
<Tr to={`/organizations/${organizationId}/policies/${policy.id}`}>
<Td>{policy.title}</Td>
<Td>
<Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")}
</Badge>
</Td>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={policy.owner.fullName} />
{policy.owner.fullName}
</div>
</Td>
<Td>
{dateFormat(policy.updatedAt, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
})}
</Td>
<Td>
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem icon={IconPencil}>{__("Edit")}</DropdownItem>
<>
<Tr to={`/organizations/${organizationId}/policies/${policy.id}`}>
<Td>{policy.title}</Td>
<Td>
<Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")}
</Badge>
</Td>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={policy.owner.fullName} />
{policy.owner.fullName}
</div>
</Td>
<Td>
{dateFormat(policy.updatedAt, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
})}
</Td>
<Td>
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end">
<ConfirmDialog
message={sprintf(
__(
@@ -208,16 +207,10 @@ function PolicyRow({
)}
onConfirm={handleDelete}
>
<DropdownItem
disabled={isDeleting}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
<Button icon={IconTrashCan} variant="danger" />
</ConfirmDialog>
</ActionDropdown>
</Td>
</Tr>
</Td>
</Tr>
</>
);
}

View File

@@ -1,12 +1,17 @@
import type { PreloadedQuery } from "react-relay";
import type { PolicyGraphNodeQuery } from "/hooks/graph/__generated__/PolicyGraphNodeQuery.graphql";
import {
ConnectionHandler,
graphql,
loadQuery,
useFragment,
usePreloadedQuery,
} from "react-relay";
import { policyNodeQuery } from "/hooks/graph/PolicyGraph";
import {
PoliciesConnectionKey,
policyNodeQuery,
useDeletePolicyMutation,
} from "/hooks/graph/PolicyGraph";
import { usePageTitle } from "@probo/hooks";
import type { PolicyPagePolicyFragment$key } from "./__generated__/PolicyPagePolicyFragment.graphql";
import { useTranslate } from "@probo/i18n";
@@ -19,10 +24,22 @@ import {
Drawer,
Badge,
Avatar,
DropdownItem,
ConfirmDialog,
ActionDropdown,
IconTrashCan,
IconPencil,
useConfirmDialogRef,
IconClock,
} from "@probo/ui";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Button } from "@probo/ui";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { sprintf } from "@probo/helpers";
import { useNavigate } from "react-router";
import UpdateVersionDialog from "./dialogs/UpdateVersionDialog";
import { useRef } from "react";
import { PolicyVersionHistoryDialog } from "./dialogs/PolicyVersionHistoryDialog";
type Props = {
queryRef: PreloadedQuery<PolicyGraphNodeQuery>;
@@ -36,7 +53,8 @@ const policyFragment = graphql`
id
fullName
}
versions(first: 1) {
versions(first: 20) @connection(key: "PolicyPage_versions") {
__id
edges {
node {
id
@@ -53,6 +71,7 @@ const policyFragment = graphql`
}
}
}
...PolicyVersionHistoryDialogFragment
}
}
}
@@ -77,6 +96,7 @@ export default function PolicyPage(props: Props) {
);
const { __, dateFormat } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const lastVersion = policy.versions.edges[0].node;
const isDraft = lastVersion.status === "DRAFT";
const [publishPolicyVersion, isPublishing] = useMutationWithToasts(
@@ -86,6 +106,10 @@ export default function PolicyPage(props: Props) {
errorMessage: __("Failed to publish policy. Please try again."),
}
);
const [deletePolicy, isDeleting] = useDeletePolicyMutation();
const versionConnectionId = policy.versions.__id;
const versions = policy.versions.edges.map((edge) => edge.node);
usePageTitle(policy.title);
const handlePublish = () => {
@@ -105,8 +129,46 @@ export default function PolicyPage(props: Props) {
});
};
const handleDelete = () => {
return new Promise<void>((resolve) => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
PoliciesConnectionKey
);
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/policies`);
},
onError: () => resolve(),
});
});
};
const updateDialogRef = useRef<{ open: () => void }>(null);
const confirmRef = useConfirmDialogRef();
return (
<>
<UpdateVersionDialog
ref={updateDialogRef}
policy={policy}
connectionId={versionConnectionId}
/>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
__(
'This will permanently delete the policy "%s". This action cannot be undone.'
),
policy.title
)}
onConfirm={handleDelete}
/>
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<Breadcrumb
@@ -130,6 +192,28 @@ export default function PolicyPage(props: Props) {
{__("Publish")}
</Button>
)}
<PolicyVersionHistoryDialog policy={policy}>
<Button icon={IconClock} variant="secondary">
{__("Version history")}
</Button>
</PolicyVersionHistoryDialog>
<ActionDropdown variant="secondary">
<DropdownItem
onClick={() => updateDialogRef.current?.open()}
icon={IconPencil}
>
{isDraft ? __("Edit draft policy") : __("Create new draft")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={() => confirmRef.current?.open()}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
</div>
<PageHeader title={policy.title} />
@@ -147,11 +231,11 @@ export default function PolicyPage(props: Props) {
</PropertyRow>
<PropertyRow label={__("Status")}>
<Badge
variant={lastVersion.status === "DRAFT" ? "highlight" : "success"}
variant={isDraft ? "highlight" : "success"}
size="md"
className="gap-2"
>
{lastVersion.status === "DRAFT" ? __("Draft") : __("Published")}
{isDraft ? __("Draft") : __("Published")}
</Badge>
</PropertyRow>
<PropertyRow label={__("Version")}>

View File

@@ -0,0 +1,135 @@
/**
* @generated SignedSource<<0c65ab6f2531f65b9687e3ca13074774>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type PolicyStatus = "DRAFT" | "PUBLISHED" | "%future added value";
export type CreateDraftPolicyVersionInput = {
policyID: string;
};
export type PolicyPageCreateDraftPolicyVersionMutation$variables = {
input: CreateDraftPolicyVersionInput;
};
export type PolicyPageCreateDraftPolicyVersionMutation$data = {
readonly createDraftPolicyVersion: {
readonly policyVersionEdge: {
readonly node: {
readonly id: string;
readonly status: PolicyStatus;
readonly version: number;
};
};
};
};
export type PolicyPageCreateDraftPolicyVersionMutation = {
response: PolicyPageCreateDraftPolicyVersionMutation$data;
variables: PolicyPageCreateDraftPolicyVersionMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "CreateDraftPolicyVersionPayload",
"kind": "LinkedField",
"name": "createDraftPolicyVersion",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionEdge",
"kind": "LinkedField",
"name": "policyVersionEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "version",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PolicyPageCreateDraftPolicyVersionMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PolicyPageCreateDraftPolicyVersionMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "197f53d67c5393a8163be2a8ce356cd0",
"id": null,
"metadata": {},
"name": "PolicyPageCreateDraftPolicyVersionMutation",
"operationKind": "mutation",
"text": "mutation PolicyPageCreateDraftPolicyVersionMutation(\n $input: CreateDraftPolicyVersionInput!\n) {\n createDraftPolicyVersion(input: $input) {\n policyVersionEdge {\n node {\n id\n version\n status\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "7fcf72158c1dc58ae8f9d7efaa6d93a9";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c59210c06978720ee4b52c41f0337ecf>>
* @generated SignedSource<<769555856fa8bff973c4685249abbb2f>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -20,6 +20,7 @@ export type PolicyPagePolicyFragment$data = {
};
readonly title: string;
readonly versions: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly content: string;
@@ -36,6 +37,7 @@ export type PolicyPagePolicyFragment$data = {
readonly status: PolicyStatus;
readonly updatedAt: any;
readonly version: number;
readonly " $fragmentSpreads": FragmentRefs<"PolicyVersionHistoryDialogFragment">;
};
}>;
};
@@ -57,7 +59,18 @@ var v0 = {
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"versions"
]
}
]
},
"name": "PolicyPagePolicyFragment",
"selections": [
(v0/*: any*/),
@@ -88,17 +101,11 @@ return {
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"alias": "versions",
"args": null,
"concreteType": "PolicyVersionConnection",
"kind": "LinkedField",
"name": "versions",
"name": "__PolicyPage_versions_connection",
"plural": false,
"selections": [
{
@@ -199,15 +206,71 @@ return {
}
],
"storageKey": "signatures(first:100)"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "PolicyVersionHistoryDialogFragment"
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": "versions(first:1)"
"storageKey": null
}
],
"type": "Policy",
@@ -215,6 +278,6 @@ return {
};
})();
(node as any).hash = "6b70d6d05d6e1081afae758389d070b2";
(node as any).hash = "684db4b721c50638ee5ec1f9f4ebdd16";
export default node;

View File

@@ -1,249 +0,0 @@
/**
* @generated SignedSource<<f3471e799b803d8bd39ad2a76603716e>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PolicyPagePolicyQuery$variables = {
id: string;
};
export type PolicyPagePolicyQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"PolicyPagePolicyFragment">;
};
};
export type PolicyPagePolicyQuery = {
response: PolicyPagePolicyQuery$data;
variables: PolicyPagePolicyQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PolicyPagePolicyQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "PolicyPagePolicyFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PolicyPagePolicyQuery",
"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": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "owner",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "PolicyVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"concreteType": "PolicyVersionSignatureConnection",
"kind": "LinkedField",
"name": "signatures",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionSignatureEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionSignature",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "signatures(first:100)"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
}
],
"type": "Policy",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "a7dd624a52cc7f0193b7670476c9de0d",
"id": null,
"metadata": {},
"name": "PolicyPagePolicyQuery",
"operationKind": "query",
"text": "query PolicyPagePolicyQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PolicyPagePolicyFragment\n id\n }\n}\n\nfragment PolicyPagePolicyFragment on Policy {\n id\n title\n description\n owner {\n id\n fullName\n }\n versions(first: 1) {\n edges {\n node {\n id\n status\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "a1317341ce16c9f3cfd8f6a6a620c077";
export default node;

View File

@@ -1,249 +0,0 @@
/**
* @generated SignedSource<<9647dfc253447fe799b8178349c656d0>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PolicyPagePolicyRefetch$variables = {
id: string;
};
export type PolicyPagePolicyRefetch$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"PolicyPagePolicyFragment">;
};
};
export type PolicyPagePolicyRefetch = {
response: PolicyPagePolicyRefetch$data;
variables: PolicyPagePolicyRefetch$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PolicyPagePolicyRefetch",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "PolicyPagePolicyFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PolicyPagePolicyRefetch",
"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": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "owner",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "PolicyVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"concreteType": "PolicyVersionSignatureConnection",
"kind": "LinkedField",
"name": "signatures",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionSignatureEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionSignature",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "signatures(first:100)"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
}
],
"type": "Policy",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "6eec3f5bb581dbce013634d36d9b43d9",
"id": null,
"metadata": {},
"name": "PolicyPagePolicyRefetch",
"operationKind": "query",
"text": "query PolicyPagePolicyRefetch(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PolicyPagePolicyFragment\n id\n }\n}\n\nfragment PolicyPagePolicyFragment on Policy {\n id\n title\n description\n owner {\n id\n fullName\n }\n versions(first: 1) {\n edges {\n node {\n id\n status\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "e553858c09ded278d1d30aaad4f7ede9";
export default node;

View File

@@ -10,24 +10,23 @@ import {
Label,
PropertyRow,
Textarea,
useDialogRef,
} from "@probo/ui";
import { useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { usePolicyForm } from "../../../hooks/forms/usePolicyForm";
import { usePolicyForm } from "../../../../hooks/forms/usePolicyForm";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import type { FormPolicyDialogMutation } from "./__generated__/FormPolicyDialogMutation.graphql";
import { PeopleSelect } from "/components/form/PeopleSelect";
import type { CreatePolicyDialogMutation } from "./__generated__/CreatePolicyDialogMutation.graphql";
type Props = {
trigger?: ReactNode;
open?: boolean;
onSuccess?: () => void;
connection: string;
};
const createPolicyMutation = graphql`
mutation FormPolicyDialogMutation(
mutation CreatePolicyDialogMutation(
$input: CreatePolicyInput!
$connections: [ID!]!
) {
@@ -44,19 +43,14 @@ const createPolicyMutation = graphql`
/**
* Dialog to create or update a policy
*/
export default function FormPolicyDialog({
trigger,
onSuccess,
open,
connection,
}: Props) {
export function CreatePolicyDialog({ trigger, connection }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { control, handleSubmit, register, formState, reset } = usePolicyForm();
const errors = formState.errors ?? {};
const [createPolicy, isLoading] =
useMutationWithToasts<FormPolicyDialogMutation>(createPolicyMutation);
useMutationWithToasts<CreatePolicyDialogMutation>(createPolicyMutation);
const onSubmit = handleSubmit((data) => {
createPolicy({
@@ -70,19 +64,17 @@ export default function FormPolicyDialog({
successMessage: __("Policy created successfully."),
errorMessage: __("Failed to create policy. Please try again."),
onSuccess: () => {
setOpen(false);
dialogRef.current?.close();
reset();
onSuccess?.();
},
});
});
const [isOpen, setOpen] = useState(!!open);
const dialogRef = useDialogRef();
return (
<Dialog
open={isOpen}
onOpenChange={setOpen}
ref={dialogRef}
trigger={trigger}
title={<Breadcrumb items={[__("Policies"), __("New Policy")]} />}
>

View File

@@ -0,0 +1,108 @@
import {
Avatar,
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
Markdown,
} from "@probo/ui";
import { graphql } from "relay-runtime";
import { useTranslate } from "@probo/i18n";
import { useState, type ReactNode } from "react";
import type {
PolicyPagePolicyFragment$data,
PolicyPagePolicyFragment$key,
} from "../__generated__/PolicyPagePolicyFragment.graphql";
import { useFragment } from "react-relay";
import type {
PolicyVersionHistoryDialogFragment$data,
PolicyVersionHistoryDialogFragment$key,
} from "./__generated__/PolicyVersionHistoryDialogFragment.graphql";
import clsx from "clsx";
import type { NodeOf } from "/types";
const historyFragment = graphql`
fragment PolicyVersionHistoryDialogFragment on PolicyVersion {
id
version
status
content
changelog
publishedAt
updatedAt
publishedBy {
fullName
}
}
`;
type Props = {
policy: PolicyPagePolicyFragment$data;
children?: ReactNode;
};
type Version = NodeOf<PolicyPagePolicyFragment$data["versions"]>;
export function PolicyVersionHistoryDialog(props: Props) {
const { __ } = useTranslate();
const versions = props.policy.versions.edges.map((edge) => edge.node);
const [selectedVersion, setSelectedVersion] = useState<Version>(versions[0]);
return (
<Dialog defaultOpen trigger={props.children}>
<DialogContent className="flex" scrollableChildren>
<aside className="p-6 overflow-y-auto w-60 flex-none">
<DialogTitle className="text-base text-txt-primary font-medium mb-4">
{__("Version History")}
</DialogTitle>
{versions.map((version) => (
<VersionItem
key={version.id}
version={version}
active={selectedVersion === version}
onSelect={setSelectedVersion}
/>
))}
</aside>
<main className="flex-1 px-12 py-8">
<Markdown content={selectedVersion.content} />
</main>
</DialogContent>
<DialogFooter></DialogFooter>
</Dialog>
);
}
function VersionItem(props: {
version: Version;
active?: boolean;
onSelect: (v: Version) => void;
}) {
const version = useFragment<PolicyVersionHistoryDialogFragment$key>(
historyFragment,
props.version
);
const { dateFormat } = useTranslate();
return (
<button
onClick={() => props.onSelect(props.version)}
className={clsx(
"flex items-center gap-1 py-2 px-[10px] w-full hover:bg-tertiary-hover cursor-pointer rounded",
props.active && "bg-tertiary-pressed"
)}
>
<Avatar name={version.publishedBy?.fullName ?? ""} size="l" />
<div className="space-y-[2px]">
<div className="text-sm text-txt-primary">
{version.publishedBy?.fullName ?? "Anonymous"}
</div>
<div className="text-xs text-txt-secondary">
{dateFormat(version.publishedAt, {
day: "numeric",
month: "short",
year: "numeric",
})}
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,174 @@
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Spinner,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { useState, type RefObject } from "react";
import { graphql } from "relay-runtime";
import { useMutation } from "react-relay";
import type { PolicyPagePolicyFragment$data } from "../__generated__/PolicyPagePolicyFragment.graphql";
import type { UpdateVersionDialogCreateMutation } from "./__generated__/UpdateVersionDialogCreateMutation.graphql";
import type { UpdateVersionDialogUpdateMutation } from "./__generated__/UpdateVersionDialogUpdateMutation.graphql";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
const createDraftPolicy = graphql`
mutation UpdateVersionDialogCreateMutation(
$input: CreateDraftPolicyVersionInput!
$connections: [ID!]!
) {
createDraftPolicyVersion(input: $input) {
policyVersionEdge @prependEdge(connections: $connections) {
node {
id
content
status
publishedAt
version
updatedAt
signatures(first: 100) {
edges {
node {
id
state
}
}
}
}
}
}
}
`;
const UpdatePolicyMutation = graphql`
mutation UpdateVersionDialogUpdateMutation(
$input: UpdatePolicyVersionInput!
) {
updatePolicyVersion(input: $input) {
policyVersion {
id
content
}
}
}
`;
type Props = {
policy: PolicyPagePolicyFragment$data;
connectionId: string;
ref: RefObject<{ open: () => void } | null>;
};
const versionSchema = z.object({
content: z.string(),
});
export default function UpdateVersionDialog({
policy,
connectionId,
ref,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const version = policy.versions.edges[0].node;
const isDraft = version?.status === "DRAFT";
const [createDraftPolicyVersion, isCreatingDraft] =
useMutation<UpdateVersionDialogCreateMutation>(createDraftPolicy);
const [updatePolicyVersion, isUpdating] =
useMutationWithToasts<UpdateVersionDialogUpdateMutation>(
UpdatePolicyMutation,
{
successMessage: __("Policy updated successfully."),
errorMessage: __("Failed to update policy. Please try again."),
}
);
const { handleSubmit, register } = useFormWithSchema(versionSchema, {
defaultValues: {
content: version.content,
},
});
ref.current = {
open: () => {
dialogRef.current?.open();
if (!isDraft) {
createDraftPolicyVersion({
variables: {
input: {
policyID: policy.id,
},
connections: [connectionId],
},
onCompleted: (_, errors) => {
if (errors) {
toast({
variant: "error",
title: __("Error creating draft"),
description:
errors[0]?.message || __("An unknown error occurred"),
});
dialogRef.current?.close();
return;
}
},
});
}
},
};
if (!version) {
return;
}
const onSubmit = handleSubmit((data) => {
updatePolicyVersion({
variables: {
input: {
policyVersionId: version.id,
content: data.content,
},
},
onSuccess: () => {
dialogRef.current?.close();
},
});
});
return (
<Dialog
ref={dialogRef}
title={<Breadcrumb items={[__("Policies"), __("Edit policy")]} />}
>
{isCreatingDraft && <Spinner centered />}
<form onSubmit={onSubmit}>
<DialogContent>
<Textarea
id="content"
variant="ghost"
autogrow
required
placeholder={__("Add description")}
aria-label={__("Description")}
className="p-6"
{...register("content")}
/>
</DialogContent>
<DialogFooter>
<Button disabled={isUpdating} type="submit">
{__("Update policy")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<418554f5765131f61a3dfed83db0da76>>
* @generated SignedSource<<0471409a1735c49fc0b05b5e63e97853>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -16,11 +16,11 @@ export type CreatePolicyInput = {
ownerId: string;
title: string;
};
export type FormPolicyDialogMutation$variables = {
export type CreatePolicyDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: CreatePolicyInput;
};
export type FormPolicyDialogMutation$data = {
export type CreatePolicyDialogMutation$data = {
readonly createPolicy: {
readonly policyEdge: {
readonly node: {
@@ -29,9 +29,9 @@ export type FormPolicyDialogMutation$data = {
};
};
};
export type FormPolicyDialogMutation = {
response: FormPolicyDialogMutation$data;
variables: FormPolicyDialogMutation$variables;
export type CreatePolicyDialogMutation = {
response: CreatePolicyDialogMutation$data;
variables: CreatePolicyDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -67,7 +67,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "FormPolicyDialogMutation",
"name": "CreatePolicyDialogMutation",
"selections": [
{
"alias": null,
@@ -118,7 +118,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "FormPolicyDialogMutation",
"name": "CreatePolicyDialogMutation",
"selections": [
{
"alias": null,
@@ -307,16 +307,16 @@ return {
]
},
"params": {
"cacheID": "5967e987ab20d7823e7456a22aec54f2",
"cacheID": "7ca1b2811e06da7ba97064950fb8f632",
"id": null,
"metadata": {},
"name": "FormPolicyDialogMutation",
"name": "CreatePolicyDialogMutation",
"operationKind": "mutation",
"text": "mutation FormPolicyDialogMutation(\n $input: CreatePolicyInput!\n) {\n createPolicy(input: $input) {\n policyEdge {\n node {\n ...PoliciesPageRowFragment\n id\n }\n }\n }\n}\n\nfragment PoliciesPageRowFragment on Policy {\n id\n title\n description\n updatedAt\n owner {\n id\n fullName\n }\n versions(first: 1) {\n edges {\n node {\n id\n status\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n }\n }\n }\n}\n"
"text": "mutation CreatePolicyDialogMutation(\n $input: CreatePolicyInput!\n) {\n createPolicy(input: $input) {\n policyEdge {\n node {\n ...PoliciesPageRowFragment\n id\n }\n }\n }\n}\n\nfragment PoliciesPageRowFragment on Policy {\n id\n title\n description\n updatedAt\n owner {\n id\n fullName\n }\n versions(first: 1) {\n edges {\n node {\n id\n status\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "c2612f85b9170d81cb9fb4908f752069";
(node as any).hash = "6c749af4def68b742bc5eb80dd120c8a";
export default node;

View File

@@ -0,0 +1,112 @@
/**
* @generated SignedSource<<929e2bdd729e8b9654b27cd9e9dbccb2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type PolicyStatus = "DRAFT" | "PUBLISHED" | "%future added value";
import { FragmentRefs } from "relay-runtime";
export type PolicyVersionHistoryDialogFragment$data = {
readonly changelog: string;
readonly content: string;
readonly id: string;
readonly publishedAt: any | null | undefined;
readonly publishedBy: {
readonly fullName: string;
} | null | undefined;
readonly status: PolicyStatus;
readonly updatedAt: any;
readonly version: number;
readonly " $fragmentType": "PolicyVersionHistoryDialogFragment";
};
export type PolicyVersionHistoryDialogFragment$key = {
readonly " $data"?: PolicyVersionHistoryDialogFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"PolicyVersionHistoryDialogFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "PolicyVersionHistoryDialogFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "version",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "content",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "changelog",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "publishedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "publishedBy",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "PolicyVersion",
"abstractKey": null
};
(node as any).hash = "63ce12a876b10889d9ff2e4ee20af968";
export default node;

View File

@@ -0,0 +1,256 @@
/**
* @generated SignedSource<<e90085131e94bcac0f94ecf5b1329712>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type PolicyStatus = "DRAFT" | "PUBLISHED" | "%future added value";
export type PolicyVersionSignatureState = "REQUESTED" | "SIGNED" | "%future added value";
export type CreateDraftPolicyVersionInput = {
policyID: string;
};
export type UpdateVersionDialogCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateDraftPolicyVersionInput;
};
export type UpdateVersionDialogCreateMutation$data = {
readonly createDraftPolicyVersion: {
readonly policyVersionEdge: {
readonly node: {
readonly content: string;
readonly id: string;
readonly publishedAt: any | null | undefined;
readonly signatures: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly state: PolicyVersionSignatureState;
};
}>;
};
readonly status: PolicyStatus;
readonly updatedAt: any;
readonly version: number;
};
};
};
};
export type UpdateVersionDialogCreateMutation = {
response: UpdateVersionDialogCreateMutation$data;
variables: UpdateVersionDialogCreateMutation$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
},
v4 = {
"alias": null,
"args": null,
"concreteType": "PolicyVersionEdge",
"kind": "LinkedField",
"name": "policyVersionEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "content",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "publishedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "version",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"concreteType": "PolicyVersionSignatureConnection",
"kind": "LinkedField",
"name": "signatures",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionSignatureEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersionSignature",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "signatures(first:100)"
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "UpdateVersionDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateDraftPolicyVersionPayload",
"kind": "LinkedField",
"name": "createDraftPolicyVersion",
"plural": false,
"selections": [
(v4/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "UpdateVersionDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateDraftPolicyVersionPayload",
"kind": "LinkedField",
"name": "createDraftPolicyVersion",
"plural": false,
"selections": [
(v4/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "policyVersionEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "c5b0f449a70a4043368de55bba176329",
"id": null,
"metadata": {},
"name": "UpdateVersionDialogCreateMutation",
"operationKind": "mutation",
"text": "mutation UpdateVersionDialogCreateMutation(\n $input: CreateDraftPolicyVersionInput!\n) {\n createDraftPolicyVersion(input: $input) {\n policyVersionEdge {\n node {\n id\n content\n status\n publishedAt\n version\n updatedAt\n signatures(first: 100) {\n edges {\n node {\n id\n state\n }\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "6a451392a86776d33c376bfdcea7613c";
export default node;

View File

@@ -0,0 +1,114 @@
/**
* @generated SignedSource<<6cbec0c0b675969ecdebebf74c7465ab>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdatePolicyVersionInput = {
content: string;
policyVersionId: string;
};
export type UpdateVersionDialogUpdateMutation$variables = {
input: UpdatePolicyVersionInput;
};
export type UpdateVersionDialogUpdateMutation$data = {
readonly updatePolicyVersion: {
readonly policyVersion: {
readonly content: string;
readonly id: string;
};
};
};
export type UpdateVersionDialogUpdateMutation = {
response: UpdateVersionDialogUpdateMutation$data;
variables: UpdateVersionDialogUpdateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdatePolicyVersionPayload",
"kind": "LinkedField",
"name": "updatePolicyVersion",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PolicyVersion",
"kind": "LinkedField",
"name": "policyVersion",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "content",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateVersionDialogUpdateMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateVersionDialogUpdateMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "374557bf1319583b7d590f5bea438e63",
"id": null,
"metadata": {},
"name": "UpdateVersionDialogUpdateMutation",
"operationKind": "mutation",
"text": "mutation UpdateVersionDialogUpdateMutation(\n $input: UpdatePolicyVersionInput!\n) {\n updatePolicyVersion(input: $input) {\n policyVersion {\n id\n content\n }\n }\n}\n"
}
};
})();
(node as any).hash = "d863d893bfa969b02768096e024275a4";
export default node;

View File

@@ -13,6 +13,7 @@ import {
PropertyRow,
IconPlusLarge,
Textarea,
useDialogRef,
} from "@probo/ui";
import { useMemo, useState, type ReactNode } from "react";
import { useFetchQuery } from "/hooks/useFetchQuery";
@@ -37,10 +38,9 @@ import { getRiskImpacts, getRiskLikelihoods } from "@probo/helpers";
type Props = {
trigger?: ReactNode;
open?: boolean;
risk?: RiskKey;
onSuccess?: () => void;
connection?: string;
ref?: ReturnType<typeof useDialogRef>;
};
type RiskTemplate = {
@@ -80,12 +80,12 @@ const updateRiskMutation = graphql`
export default function FormRiskDialog({
trigger,
risk,
onSuccess,
open,
connection,
ref: refProps,
}: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const ref = refProps ?? useDialogRef();
const { control, handleSubmit, setValue, register, watch, formState, reset } =
useRiskForm(risk);
@@ -113,8 +113,7 @@ export default function FormRiskDialog({
successMessage: __("Risk updated successfully."),
errorMessage: __("Failed to update risk. Please try again."),
onSuccess: () => {
setOpen(false);
onSuccess?.();
ref?.current?.close();
},
});
return;
@@ -130,20 +129,17 @@ export default function FormRiskDialog({
successMessage: __("Risk created successfully."),
errorMessage: __("Failed to create risk. Please try again."),
onSuccess: () => {
setOpen(false);
ref?.current?.close();
reset();
onSuccess?.();
},
});
});
const [showNote, toggleNote] = useToggle(false);
const [isOpen, setOpen] = useState(!!open);
return (
<Dialog
open={isOpen}
onOpenChange={setOpen}
ref={ref}
trigger={trigger}
title={
<Breadcrumb

View File

@@ -13,6 +13,7 @@ import {
PropertyRow,
TabLink,
Tabs,
useConfirmDialogRef,
} from "@probo/ui";
import { Outlet, useNavigate, useParams } from "react-router";
import { useTranslate } from "@probo/i18n";
@@ -54,17 +55,22 @@ export default function RiskDetailPage(props: Props) {
organizationId,
RisksConnectionKey
);
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/risks`);
},
return new Promise<void>((resolve) => {
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/risks`);
resolve();
},
});
});
};
const confirmRef = useConfirmDialogRef();
return (
<div className="space-y-6">
{/* Header */}
@@ -90,23 +96,28 @@ export default function RiskDetailPage(props: Props) {
risk={{ id: riskId, ...risk }}
/>
<ActionDropdown variant="secondary">
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(riskId)}
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={() => confirmRef.current?.open()}
>
<DropdownItem variant="danger" icon={IconTrashCan}>
{__("Delete")}
</DropdownItem>
</ConfirmDialog>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
</div>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(riskId)}
/>
<PageHeader title={risk.name} />
<Tabs>

View File

@@ -14,6 +14,8 @@ import {
Th,
Thead,
Tr,
useConfirmDialogRef,
useDialogRef,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import FormRiskDialog from "./FormRiskDialog";
@@ -21,11 +23,12 @@ import { useOrganizationId } from "/hooks/useOrganizationId";
import { useState } from "react";
import { usePageTitle } from "@probo/hooks";
import { getTreatment, sprintf } from "@probo/helpers";
import type { ItemOf } from "/types";
import type { ItemOf, NodeOf } from "/types";
import { useDeleteRiskMutation, useRisksQuery } from "/hooks/graph/RiskGraph";
import { SortableTable, SortableTh } from "/components/SortableTable";
import type { PreloadedQuery } from "react-relay";
import type { RiskGraphListQuery } from "/hooks/graph/__generated__/RiskGraphListQuery.graphql";
import type { RiskGraphFragment$data } from "/hooks/graph/__generated__/RiskGraphFragment.graphql";
type Props = {
queryRef: PreloadedQuery<RiskGraphListQuery>;
@@ -33,23 +36,10 @@ type Props = {
export default function RisksPage(props: Props) {
const { __ } = useTranslate();
const [editedRisk, setEditedRisk] = useState<ItemOf<typeof risks> | null>(
null
);
const organizationId = useOrganizationId();
const [deleteRisk] = useDeleteRiskMutation();
const { connectionId, risks, refetch } = useRisksQuery(props.queryRef);
const onDelete = (riskId: string) => {
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
});
};
usePageTitle(__("Risks"));
return (
@@ -60,14 +50,6 @@ export default function RisksPage(props: Props) {
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
</PageHeader>
{editedRisk && (
<FormRiskDialog
open
risk={editedRisk}
connection={connectionId}
onSuccess={() => setEditedRisk(null)}
/>
)}
<div className="grid grid-cols-2 gap-4">
<RisksChart
@@ -98,46 +80,88 @@ export default function RisksPage(props: Props) {
</Thead>
<Tbody>
{risks?.map((risk) => (
<Tr
<RiskRow
risk={risk}
key={risk.id}
to={`/organizations/${organizationId}/risks/${risk.id}`}
>
<Td>{risk.name}</Td>
<Td>{risk.category}</Td>
<Td>{getTreatment(__, risk.treatment)}</Td>
<Td>
<SeverityBadge score={risk.inherentRiskScore} />
</Td>
<Td>
<SeverityBadge score={risk.residualRiskScore} />
</Td>
<Td noLink className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => setEditedRisk(risk)}
>
{__("Edit")}
</DropdownItem>
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(risk.id)}
>
<DropdownItem variant="danger" icon={IconTrashCan}>
{__("Delete")}
</DropdownItem>
</ConfirmDialog>
</ActionDropdown>
</Td>
</Tr>
connectionId={connectionId}
organizationId={organizationId}
/>
))}
</Tbody>
</SortableTable>
</div>
);
}
type RowProps = {
risk: NodeOf<RiskGraphFragment$data["risks"]>;
connectionId: string;
organizationId: string;
};
function RiskRow(props: RowProps) {
const { __ } = useTranslate();
const { risk, connectionId, organizationId } = props;
const [deleteRisk] = useDeleteRiskMutation();
const onDelete = (riskId: string) => {
return new Promise<void>((resolve) => {
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
});
};
const confirmRef = useConfirmDialogRef();
const formDialogRef = useDialogRef();
return (
<>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(risk.id)}
/>
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
<Tr to={`/organizations/${organizationId}/risks/${risk.id}`}>
<Td>{risk.name}</Td>
<Td>{risk.category}</Td>
<Td>{getTreatment(__, risk.treatment)}</Td>
<Td>
<SeverityBadge score={risk.inherentRiskScore} />
</Td>
<Td>
<SeverityBadge score={risk.residualRiskScore} />
</Td>
<Td noLink className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => confirmRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={() => confirmRef.current?.open()}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
</Tr>
</>
);
}

View File

@@ -15,6 +15,7 @@ import {
DropdownItem,
IconTrashCan,
ConfirmDialog,
useConfirmDialogRef,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks";
@@ -134,66 +135,77 @@ function VendorRow({
const [deleteVendor] = useDeleteVendorMutation();
const onDelete = (vendorId: string) => {
deleteVendor({
variables: {
input: {
vendorId,
return new Promise<void>((resolve) => {
deleteVendor({
variables: {
input: {
vendorId,
},
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"VendorsPage_vendors"
),
],
},
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"VendorsPage_vendors"
),
],
},
onCompleted: () => resolve(),
});
});
};
const confirmRef = useConfirmDialogRef();
return (
<Tr to={`/organizations/${organizationId}/vendors/${vendor.id}`}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
<div>{vendor.name}</div>
</div>
</Td>
<Td>
{latestAssessment?.assessedAt
? dateFormat(latestAssessment.assessedAt, {
day: "2-digit",
weekday: "short",
month: "short",
})
: __("Not assessed")}
</Td>
<Td>
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
</Td>
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td>
<Badge variant={isExpired ? "danger" : "warning"}>
{isExpired ? __("Late") : __("In progress")}
</Badge>
</Td>
<Td noLink>
<ActionDropdown>
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the vendor "%s". This action cannot be undone.'
),
vendor.name
)}
onConfirm={() => onDelete(vendor.id)}
>
<DropdownItem variant="danger" icon={IconTrashCan}>
<>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
__(
'This will permanently delete the vendor "%s". This action cannot be undone.'
),
vendor.name
)}
onConfirm={() => onDelete(vendor.id)}
/>
<Tr to={`/organizations/${organizationId}/vendors/${vendor.id}`}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
<div>{vendor.name}</div>
</div>
</Td>
<Td>
{latestAssessment?.assessedAt
? dateFormat(latestAssessment.assessedAt, {
day: "2-digit",
weekday: "short",
month: "short",
})
: __("Not assessed")}
</Td>
<Td>
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
</Td>
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td>
<Badge variant={isExpired ? "danger" : "warning"}>
{isExpired ? __("Late") : __("In progress")}
</Badge>
</Td>
<Td noLink>
<ActionDropdown>
<DropdownItem
onClick={() => confirmRef.current?.open()}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ConfirmDialog>
</ActionDropdown>
</Td>
</Tr>
</ActionDropdown>
</Td>
</Tr>
</>
);
}