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

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"relay": "npx relay-compiler",
"relay": "find src -type d -name \"__generated\" -exec rm -rf {} + && npx relay-compiler",
"build": "tsc -b && vite build",
"lint": "eslint .",
"check": "tsc --noEmit -p tsconfig.app.json",

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>
</>
);
}

View File

@@ -8,13 +8,15 @@
"lint": "eslint .",
"dev": "storybook dev -p 6006 --no-open",
"build": "storybook build",
"check": "tsc --noEmit -p tsconfig.app.json"
"check": "tsc --noEmit -p tsconfig.app.json",
"icons": "bun run src/Atoms/Icons/generator.ts"
},
"prettier": "@probo/prettier",
"dependencies": {
"@ariakit/react": "^0.4.17",
"@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-scroll-area": "^1.2.9",

View File

@@ -8,7 +8,7 @@ type Props = {
export function Avatar({ name, src, size = "m" }: Props) {
const className = clsx(
"bg-txt-success text-txt-invert rounded-full font-semibold flex items-center justify-center",
"bg-txt-success text-txt-invert rounded-full font-semibold flex items-center justify-center flex-none",
size === "s" && "size-5 text-xss",
size === "m" && "size-6 text-xxs",
size === "l" && "size-8 text-sm",

View File

@@ -62,6 +62,7 @@ export const Button = (props: Props) => {
iconAfter: IconAfterComponent,
children,
onClick,
variant,
...componentProps
} = props;
return (
@@ -69,7 +70,7 @@ export const Button = (props: Props) => {
<Component
{...componentProps}
onClick={onClick}
className={button({ ...props, empty: !children })}
className={button({ ...props, variant, empty: !children })}
>
{IconComponent && <IconComponent size={16} className="flex-none" />}
{children}

View File

@@ -0,0 +1,8 @@
import type {IconProps} from "./type.ts";
export function IconClock({size = 24, className}: IconProps) {
return <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" className={className} xmlns="http://www.w3.org/2000/svg">
<path d="M13.3335 8.00065C13.3335 5.05513 10.9457 2.66732 8.00016 2.66732C5.05465 2.66732 2.66683 5.05513 2.66683 8.00065C2.66683 10.9462 5.05464 13.334 8.00016 13.334C10.9457 13.334 13.3335 10.9462 13.3335 8.00065ZM7.3335 5.33398C7.3335 4.96579 7.63197 4.66732 8.00016 4.66732C8.36835 4.66732 8.66683 4.96579 8.66683 5.33398V7.72461L10.1382 9.19596L10.1838 9.24675C10.3973 9.50859 10.3823 9.8946 10.1382 10.1387C9.89411 10.3827 9.50811 10.3978 9.24626 10.1842L9.19548 10.1387L7.52881 8.47201C7.40378 8.34698 7.3335 8.17746 7.3335 8.00065V5.33398ZM14.6668 8.00065C14.6668 11.6826 11.6821 14.6673 8.00016 14.6673C4.31827 14.6673 1.3335 11.6826 1.3335 8.00065C1.3335 4.31875 4.31827 1.33398 8.00016 1.33398C11.6821 1.33398 14.6668 4.31876 14.6668 8.00065Z" fill="currentColor"/>
</svg>
}

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3335 8.00065C13.3335 5.05513 10.9457 2.66732 8.00016 2.66732C5.05465 2.66732 2.66683 5.05513 2.66683 8.00065C2.66683 10.9462 5.05464 13.334 8.00016 13.334C10.9457 13.334 13.3335 10.9462 13.3335 8.00065ZM7.3335 5.33398C7.3335 4.96579 7.63197 4.66732 8.00016 4.66732C8.36835 4.66732 8.66683 4.96579 8.66683 5.33398V7.72461L10.1382 9.19596L10.1838 9.24675C10.3973 9.50859 10.3823 9.8946 10.1382 10.1387C9.89411 10.3827 9.50811 10.3978 9.24626 10.1842L9.19548 10.1387L7.52881 8.47201C7.40378 8.34698 7.3335 8.17746 7.3335 8.00065V5.33398ZM14.6668 8.00065C14.6668 11.6826 11.6821 14.6673 8.00016 14.6673C4.31827 14.6673 1.3335 11.6826 1.3335 8.00065C1.3335 4.31875 4.31827 1.33398 8.00016 1.33398C11.6821 1.33398 14.6668 4.31876 14.6668 8.00065Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 873 B

View File

@@ -12,6 +12,7 @@ export { IconCrossLargeX } from "./IconCrossLargeX.tsx";
export { IconUpload } from "./IconUpload.tsx";
export { IconListStack } from "./IconListStack.tsx";
export { IconCircleCheck1 } from "./IconCircleCheck1.tsx";
export { IconClock } from "./IconClock.tsx";
export { IconImport2 } from "./IconImport2.tsx";
export { IconChevronTriangleDownSmall } from "./IconChevronTriangleDownSmall.tsx";
export { IconCheckmark1 } from "./IconCheckmark1.tsx";

View File

@@ -1,8 +1,8 @@
import {
useRef,
type FormEventHandler,
type TextareaHTMLAttributes,
type RefObject,
type RefCallback,
useLayoutEffect,
} from "react";
import { input } from "../Input/Input";
import clsx from "clsx";
@@ -10,26 +10,35 @@ import clsx from "clsx";
type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
variant?: "bordered" | "ghost" | "title";
autogrow?: boolean;
ref?: RefObject<HTMLTextAreaElement>;
ref?: RefCallback<HTMLTextAreaElement>;
};
export function Textarea(props: Props) {
const textbox = useRef<HTMLTextAreaElement>(null);
const ref = useRef<HTMLTextAreaElement>(null);
const { autogrow, variant, ref: propsRef, ...restProps } = props;
const ref = propsRef || textbox;
const adjustHeight: FormEventHandler<HTMLTextAreaElement> = (e) => {
props.onInput?.(e);
const adjustHeight = () => {
if (!autogrow || !ref.current) return;
ref.current.style.height = "inherit";
const paddingY = 2;
ref.current.style.height = `${ref.current.scrollHeight + paddingY * 2}px`;
};
useLayoutEffect(() => {
adjustHeight();
}, []);
return (
<textarea
{...restProps}
ref={ref}
onInput={adjustHeight}
ref={(node) => {
ref.current = node;
propsRef?.(node);
}}
onInput={(e) => {
adjustHeight();
props.onInput?.(e);
}}
className={input({
...props,
className: clsx("min-h-20", props.className),

View File

@@ -19,7 +19,13 @@ export const Default: Story = {
return (
<ConfirmDialog
message="Are you sure you want to delete this risk?"
onConfirm={() => {}}
onConfirm={() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
}}
>
<Button variant="danger">Delete this</Button>
</ConfirmDialog>

View File

@@ -1,16 +1,23 @@
import { useTranslate } from "@probo/i18n";
import { Dialog, DialogContent, DialogFooter } from "./Dialog";
import {
Children,
cloneElement,
isValidElement,
Root,
Trigger,
Overlay,
Content,
Title,
Description,
Cancel,
} from "@radix-ui/react-alert-dialog";
import {
useRef,
useState,
type ComponentProps,
type MouseEvent,
type ReactNode,
type RefObject,
} from "react";
import { Button } from "../../Atoms/Button/Button";
import { Root as Portal } from "@radix-ui/react-portal";
import { dialog } from "./Dialog";
type Props = {
children?: ReactNode;
@@ -18,7 +25,12 @@ type Props = {
message: string;
variant?: ComponentProps<typeof Button>["variant"];
label?: string;
onConfirm?: () => void;
onConfirm?: () => Promise<void>;
ref?: RefObject<{ open: () => void } | null>;
};
export const useConfirmDialogRef = () => {
return useRef<{ open: () => void } | null>(null);
};
export function ConfirmDialog(props: Props) {
@@ -27,45 +39,52 @@ export function ConfirmDialog(props: Props) {
const variant = props.variant ?? "danger";
const label = variant === "danger" ? __("Delete") : props.label;
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const {
overlay,
content,
header,
footer,
title: titleClassname,
} = dialog();
const onConfirm = () => {
setOpen(false);
props.onConfirm?.();
document.body.click();
};
const children = Children.map(props.children, (child) => {
if (!isValidElement(child)) {
throw new Error("Cannot use non element children");
}
return cloneElement(child, {
// @ts-expect-error We inject a handler on an unknown element
onClick: (e: MouseEvent) => {
e.preventDefault();
setOpen(true);
},
setLoading(true);
props.onConfirm?.().then(() => {
setLoading(false);
setOpen(false);
});
});
};
if (props.ref) {
props.ref.current = {
open: () => setOpen(true),
};
}
return (
<>
{children}
<Root open={open} onOpenChange={setOpen}>
{props.children && <Trigger asChild children={props.children} />}
<Portal>
<Dialog
open={open}
onOpenChange={setOpen}
title={title}
className="max-w-[500px]"
>
<DialogContent>
<p className="text-sm text-txt-secondary p-4">
{props.message}
</p>
</DialogContent>
<DialogFooter>
<Button variant={variant} onClick={onConfirm}>
<Overlay className={overlay()} />
<Content className={content({ className: "max-w-[500px]" })}>
<header className={header()}>
<Title children={title} className={titleClassname()} />
</header>
<Description className="p-6" children={props.message} />
<footer className={footer()}>
<Cancel asChild>
<Button disabled={loading} variant="tertiary">
{__("Cancel")}
</Button>
</Cancel>
<Button
disabled={loading}
variant={variant}
onClick={onConfirm}
>
{label}
</Button>
</DialogFooter>
</Dialog>
</footer>
</Content>
</Portal>
</>
</Root>
);
}

View File

@@ -8,63 +8,100 @@ import {
Close,
} from "@radix-ui/react-dialog";
import { IconCrossLargeX } from "../../Atoms/Icons";
import { type HTMLAttributes, type ReactNode } from "react";
import {
Children,
cloneElement,
isValidElement,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type HTMLAttributes,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { Button } from "../../Atoms/Button/Button";
import { useTranslate } from "@probo/i18n";
import clsx from "clsx";
import { tv } from "tailwind-variants";
export const dialog = tv({
slots: {
overlay:
"fixed inset-0 z-50 bg-dialog/40 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
content:
"text-txt-secondary text-sm fixed inset-0 m-auto z-50 w-full h-max bg-level-2 rounded-2xl max-w-5xl w-[95%] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-5 data-[state=open]:slide-in-from-top-5",
header: "flex justify-between items-center p-3 border-b border-b-border-low",
title: "text-sm font-medium text-txt-primary",
footer: "flex justify-end items-center p-3 border-t border-t-border-low gap-2",
},
});
type Props = {
trigger?: ReactNode;
title: ReactNode;
title?: ReactNode;
children?: ReactNode;
onOpenChange?: (open: boolean) => void;
open?: boolean;
defaultOpen?: boolean;
className?: string;
ref?: RefObject<{ open: () => void; close: () => void } | null>;
};
export const useDialogRef = () => {
return useRef<{ open: () => void; close: () => void } | null>(null);
};
export function Dialog({
trigger,
title,
children,
onOpenChange,
open,
className,
ref,
defaultOpen,
}: Props) {
const { overlay, content, header, title: titleClassname } = dialog();
const [open, setOpen] = useState(!!defaultOpen);
if (ref) {
ref.current = {
open() {
setOpen(true);
},
close() {
setOpen(false);
},
};
}
return (
<Root open={open} onOpenChange={onOpenChange}>
<Root open={open} onOpenChange={setOpen}>
{trigger && <Trigger asChild>{trigger}</Trigger>}
<Portal>
<Overlay
className={clsx(
"fixed inset-0 z-50 bg-dialog/40",
`duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0`,
)}
/>
<Overlay className={overlay()} />
<Content
aria-describedby={undefined}
className={clsx(
"fixed inset-0 m-auto z-50 w-full h-max bg-level-2 rounded-2xl max-w-5xl w-[95%]",
className,
`duration-200
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[state=closed]:slide-out-to-top-5
data-[state=open]:slide-in-from-top-5`,
)}
className={content({ className })}
>
<div className="flex justify-between items-center p-3 border-b border-b-border-low">
<Title className="text-sm font-medium text-txt-primary">
{title}
</Title>
{title ? (
<div className={header()}>
<Title className={titleClassname()}> {title}</Title>
<Close asChild>
<Button
tabIndex={-1}
variant="tertiary"
icon={IconCrossLargeX}
/>
</Close>
</div>
) : (
<Close asChild>
<Button
tabIndex={-1}
variant="tertiary"
className="absolute top-4 right-4"
icon={IconCrossLargeX}
/>
</Close>
</div>
)}
{children}
</Content>
</Portal>
@@ -80,8 +117,9 @@ export function DialogFooter({
exitLabel?: string;
}) {
const { __ } = useTranslate();
const { footer } = dialog();
return (
<footer className="flex justify-end items-center p-3 border-t border-t-border-low gap-2">
<footer className={footer()}>
<Close asChild>
<Button variant="secondary">{exitLabel ?? __("Cancel")}</Button>
</Close>
@@ -91,19 +129,51 @@ export function DialogFooter({
}
export function DialogContent(
props: HTMLAttributes<HTMLDivElement> & { padded?: boolean },
props: HTMLAttributes<HTMLDivElement> & {
padded?: boolean;
scrollableChildren?: boolean;
},
) {
let children = props.children;
if (props.scrollableChildren) {
children = Children.map(props.children, (c) => {
if (
isValidElement<{
className?: string;
style?: CSSProperties;
}>(c)
) {
return cloneElement(c, {
...c.props,
className: clsx(c.props.className, "overflow-y-auto"),
style: {
maxHeight: "var(--maxHeight)",
...c.props.style,
},
});
}
return c;
});
}
return (
<div
{...props}
children={children}
className={clsx(
"overflow-y-auto",
props.className,
props.padded && "p-6",
)}
style={{
maxHeight: "min(640px, calc(100vh - 140px))",
}}
style={
{
"--maxHeight": "min(640px, calc(100vh - 140px))",
maxHeight: "var(--maxHeight)",
} as CSSProperties
}
/>
);
}
export function DialogTitle(props: ComponentProps<typeof Title>) {
return <Title {...props} />;
}

View File

@@ -40,10 +40,19 @@ export {
} from "./Molecules/UserDropdown/UserDropdown";
export { PageHeader } from "./Molecules/PageHeader/PageHeader";
export { Skeleton } from "./Atoms/Skeleton/Skeleton";
export { Dialog, DialogContent, DialogFooter } from "./Molecules/Dialog/Dialog";
export {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
useDialogRef,
} from "./Molecules/Dialog/Dialog";
export { RiskBadge } from "./Molecules/Badge/RiskBadge";
export { SeverityBadge } from "./Molecules/Badge/SeverityBadge.tsx";
export { ConfirmDialog } from "./Molecules/Dialog/ConfirmDialog.tsx";
export {
ConfirmDialog,
useConfirmDialogRef,
} from "./Molecules/Dialog/ConfirmDialog.tsx";
export { RisksChart } from "./Molecules/Risks/RisksChart";
export { RiskOverview } from "./Molecules/Risks/RiskOverview";
export { Combobox, ComboboxItem } from "./Molecules/Combobox/Combobox";

View File

@@ -244,7 +244,6 @@
*:focus-visible {
box-shadow: var(--shadow-focus);
outline: none;
border-radius: 0.25rem;
}
::-webkit-scrollbar {

View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
typography: () => ({
neutral: {
css: {
"--tw-prose-body": "var(--color-txt-secondary)",
"--tw-prose-headings": "var(--color-txt-primary)",
"--tw-prose-lead": "var(--color-txt-secondary)",
"--tw-prose-links": "var(--color-txt-secondary)",
"--tw-prose-bold": "var(--color-txt-secondary)",
"--tw-prose-counters": "var(--color-txt-secondary)",
"--tw-prose-bullets": "var(--color-txt-secondary)",
"--tw-prose-hr": "var(--color-txt-secondary)",
"--tw-prose-quotes": "var(--color-txt-secondary)",
"--tw-prose-quote-borders": "var(--color-txt-primary)",
"--tw-prose-captions": "var(--color-txt-secondary)",
"--tw-prose-code": "var(--color-txt-secondary)",
"--tw-prose-pre-code": "var(--color-txt-secondary)",
"--tw-prose-pre-bg": "var(--color-txt-secondary)",
"--tw-prose-th-borders": "var(--color-txt-secondary)",
"--tw-prose-td-borders": "var(--color-txt-secondary)",
},
},
}),
},
},
};