Add risk publish to document system
Replace the old snapshot-based system for risks with the publish document system, mirroring the prior vendor / processing activity / DPIA / TIA migration. Includes the GraphQL mutation, MCP tool, CLI command, n8n operation, frontend publish dialog, e2e tests, and a prosemirror register template covering name, description, category, treatment, owner, inherent and residual scoring, and notes. The risk register lives as a generated DocumentTypeRegister document on the organization, reused across publishes (the major version bumps on every republish). Approvers can be passed in to create a draft pending approval; otherwise the version is published immediately. The frontend Risks page exposes a Publish button and a Document link button when the document exists, and pre-fills the previous default approvers. Risks was the last remaining snapshot type, so this commit also removes the entire snapshot system: drop snapshotId from the Risk GraphQL type and RiskFilter; remove RiskSnapshotter, Risks.Snapshot, InsertRiskSnapshots, and the SnapshotID/SourceID fields on Risk; delete Snapshot, ControlSnapshot, SnapshotsType, SnapshotOrderField, Snapshottable, the SnapshotService, the Snapshot console resolvers and GraphQL schema, the Snapshot MCP types and operations (list/get/take/listControlSnapshots), the snapshot CLI (prb snapshot), the snapshot frontend pages, routes, banner, LinkedSnapshotsCard, SnapshotGraph, snapshot helpers, and the snapshot n8n resource and control link/unlink snapshot operations. The snapshot_id columns remain in the database but are now filtered out with snapshot_id IS NULL. Add Get/Upsert/Clear GeneratedDocumentID methods on Risk backed by a new risks_document_id column on generated_documents, matching the ProcessingActivity/Finding/Vendor pattern. The migration command migrate-risk-snapshots-to-documents uses raw SQL queries instead of the Go snapshot types, since those are gone. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -19,7 +19,6 @@ import {
|
||||
IconBook,
|
||||
IconBox,
|
||||
IconCircleProgress,
|
||||
IconClock,
|
||||
IconFire3,
|
||||
IconGroup1,
|
||||
IconInboxEmpty,
|
||||
@@ -62,7 +61,6 @@ const fragment = graphql`
|
||||
action: "core:processing-activity:list"
|
||||
)
|
||||
canListRightsRequests: permission(action: "core:rights-request:list")
|
||||
canListSnapshots: permission(action: "core:snapshot:list")
|
||||
canGetTrustCenter: permission(action: "core:trust-center:get")
|
||||
canListCookieBanners: permission(action: "core:cookie-banner:list")
|
||||
canUpdateOrganization: permission(action: "iam:organization:update")
|
||||
@@ -199,13 +197,6 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
|
||||
to={`${prefix}/rights-requests`}
|
||||
/>
|
||||
)}
|
||||
{organization.canListSnapshots && (
|
||||
<SidebarItem
|
||||
label={__("Snapshots")}
|
||||
icon={IconClock}
|
||||
to={`${prefix}/snapshots`}
|
||||
/>
|
||||
)}
|
||||
{organization.canListAccessReviewCampaigns && (
|
||||
<SidebarItem
|
||||
label={__("Access Reviews")}
|
||||
|
||||
@@ -45,7 +45,6 @@ import { LinkedAuditsCard } from "#/components/audits/LinkedAuditsCard";
|
||||
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
|
||||
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
|
||||
import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard";
|
||||
import { LinkedSnapshotsCard } from "#/components/snapshots/LinkedSnapshotsCard";
|
||||
import { frameworkControlNodeQuery } from "#/hooks/graph/FrameworkGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
@@ -159,33 +158,6 @@ const detachObligationMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const attachSnapshotMutation = graphql`
|
||||
mutation FrameworkControlPageAttachSnapshotMutation(
|
||||
$input: CreateControlSnapshotMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createControlSnapshotMapping(input: $input) {
|
||||
snapshotEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
...LinkedSnapshotsCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const detachSnapshotMutation = graphql`
|
||||
mutation FrameworkControlPageDetachSnapshotMutation(
|
||||
$input: DeleteControlSnapshotMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteControlSnapshotMapping(input: $input) {
|
||||
deletedSnapshotId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteControlMutation = graphql`
|
||||
mutation FrameworkControlPageDeleteControlMutation(
|
||||
$input: DeleteControlInput!
|
||||
@@ -236,14 +208,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
const [attachAudit, isAttachingAudit] = useMutation(attachAuditMutation);
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
const [detachSnapshot, isDetachingSnapshot] = useMutation(
|
||||
detachSnapshotMutation,
|
||||
);
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
const [attachSnapshot, isAttachingSnapshot] = useMutation(
|
||||
attachSnapshotMutation,
|
||||
);
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
const [deleteControl] = useMutation(deleteControlMutation);
|
||||
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
@@ -267,10 +231,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
|
||||
const canUnlinkAudit = control.canDeleteAuditMapping;
|
||||
const auditsReadOnly = !canLinkAudit && !canUnlinkAudit;
|
||||
|
||||
const canLinkSnapshot = control.canCreateSnapshotMapping;
|
||||
const canUnlinkSnapshot = control.canDeleteSnapshotMapping;
|
||||
const snapshotsReadOnly = !canLinkSnapshot && !canUnlinkSnapshot;
|
||||
|
||||
const canLinkObligation = control.canCreateObligationMapping;
|
||||
const canUnlinkObligation = control.canDeleteObligationMapping;
|
||||
const obligationsReadOnly = !canLinkObligation && !canUnlinkObligation;
|
||||
@@ -482,27 +442,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
|
||||
readOnly={obligationsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<LinkedSnapshotsCard
|
||||
variant="card"
|
||||
snapshots={
|
||||
control.snapshots?.edges.map(edge => edge.node)
|
||||
?? []
|
||||
}
|
||||
params={{ controlId: control.id }}
|
||||
connectionId={control.snapshots?.__id ?? ""}
|
||||
onAttach={withErrorHandling(
|
||||
attachSnapshot,
|
||||
__("Failed to link snapshot"),
|
||||
)}
|
||||
onDetach={withErrorHandling(
|
||||
detachSnapshot,
|
||||
__("Failed to unlink snapshot"),
|
||||
)}
|
||||
disabled={isAttachingSnapshot || isDetachingSnapshot}
|
||||
readOnly={snapshotsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -114,10 +114,9 @@ export default function MeasureEvidencesTab() {
|
||||
const { measure } = useOutletContext<{
|
||||
measure: MeasureEvidencesTabFragment$key;
|
||||
}>();
|
||||
const { measureId, evidenceId, snapshotId } = useParams<{
|
||||
const { measureId, evidenceId } = useParams<{
|
||||
measureId: string;
|
||||
evidenceId: string;
|
||||
snapshotId?: string;
|
||||
}>();
|
||||
if (!measureId) {
|
||||
throw new Error("Missing :measureId param in route");
|
||||
@@ -132,7 +131,6 @@ export default function MeasureEvidencesTab() {
|
||||
const evidence = evidences.find(e => e.id === evidenceId);
|
||||
const organizationId = useOrganizationId();
|
||||
const dialogRef = useDialogRef();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
usePageTitle(pagination.data.name + " - " + __("Evidences"));
|
||||
|
||||
@@ -156,11 +154,9 @@ export default function MeasureEvidencesTab() {
|
||||
measureId={measureId}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
hideActions={isSnapshotMode}
|
||||
snapshotId={snapshotId}
|
||||
/>
|
||||
))}
|
||||
{!isSnapshotMode && pagination.data.canUploadEvidence && (
|
||||
{pagination.data.canUploadEvidence && (
|
||||
<TrButton
|
||||
colspan={5}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
@@ -175,16 +171,13 @@ export default function MeasureEvidencesTab() {
|
||||
<EvidencePreviewDialog
|
||||
key={evidence.id}
|
||||
onClose={() => {
|
||||
const baseUrl = isSnapshotMode
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/measures/${measureId}/evidences`
|
||||
: `/organizations/${organizationId}/measures/${measureId}/evidences`;
|
||||
void navigate(baseUrl);
|
||||
void navigate(`/organizations/${organizationId}/measures/${measureId}/evidences`);
|
||||
}}
|
||||
evidenceId={evidence.id}
|
||||
filename={evidence.file?.fileName || ""}
|
||||
/>
|
||||
)}
|
||||
{!isSnapshotMode && pagination.data.canUploadEvidence && (
|
||||
{pagination.data.canUploadEvidence && (
|
||||
<CreateEvidenceDialog
|
||||
ref={dialogRef}
|
||||
measureId={measureId}
|
||||
@@ -200,8 +193,6 @@ function EvidenceRow(props: {
|
||||
measureId: string;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
hideActions?: boolean;
|
||||
snapshotId?: string;
|
||||
}) {
|
||||
const evidence = useFragment(evidenceFragment, props.evidenceKey);
|
||||
const { __ } = useTranslate();
|
||||
@@ -253,9 +244,7 @@ function EvidenceRow(props: {
|
||||
);
|
||||
};
|
||||
|
||||
const evidenceUrl = props.snapshotId
|
||||
? `/organizations/${props.organizationId}/snapshots/${props.snapshotId}/risks/measures/${props.measureId}/evidences/${evidence.id}`
|
||||
: `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`;
|
||||
const evidenceUrl = `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -275,26 +264,24 @@ function EvidenceRow(props: {
|
||||
<Td>{fileSize(__, evidence.file?.size || 0)}</Td>
|
||||
<Td>{formatDate(evidence.createdAt)}</Td>
|
||||
<Td noLink>
|
||||
{!props.hideActions && (
|
||||
<div className="flex gap-2">
|
||||
<ActionDropdown>
|
||||
<DropdownItem onClick={() => setIsDownloading(true)}>
|
||||
<IconArrowInbox size={16} />
|
||||
{__("Download")}
|
||||
<div className="flex gap-2">
|
||||
<ActionDropdown>
|
||||
<DropdownItem onClick={() => setIsDownloading(true)}>
|
||||
<IconArrowInbox size={16} />
|
||||
{__("Download")}
|
||||
</DropdownItem>
|
||||
{evidence.canDelete && (
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
{evidence.canDelete && (
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import {
|
||||
getTreatment,
|
||||
sprintf,
|
||||
validateSnapshotConsistency,
|
||||
} from "@probo/helpers";
|
||||
import { getTreatment, sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
@@ -41,7 +37,6 @@ import { Outlet, useNavigate, useParams } from "react-router";
|
||||
import { ConnectionHandler } from "relay-runtime";
|
||||
|
||||
import type { RiskGraphNodeQuery } from "#/__generated__/core/RiskGraphNodeQuery.graphql";
|
||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
||||
import {
|
||||
riskNodeQuery,
|
||||
RisksConnectionKey,
|
||||
@@ -56,13 +51,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function RiskDetailPage(props: Props) {
|
||||
const { riskId, snapshotId } = useParams<{
|
||||
const { riskId } = useParams<{
|
||||
riskId: string;
|
||||
snapshotId?: string;
|
||||
}>();
|
||||
const organizationId = useOrganizationId();
|
||||
const navigate = useNavigate();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
if (!riskId) {
|
||||
throw new Error("Cannot load risk detail page without riskId parameter");
|
||||
@@ -74,7 +67,6 @@ export default function RiskDetailPage(props: Props) {
|
||||
props.queryRef,
|
||||
);
|
||||
|
||||
validateSnapshotConsistency(risk, snapshotId);
|
||||
const [deleteRisk] = useDeleteRiskMutation();
|
||||
|
||||
usePageTitle(risk.name ?? "Risk detail");
|
||||
@@ -84,7 +76,6 @@ export default function RiskDetailPage(props: Props) {
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
RisksConnectionKey,
|
||||
{ filter: { snapshotId: snapshotId || null } },
|
||||
);
|
||||
confirm(
|
||||
() =>
|
||||
@@ -95,11 +86,7 @@ export default function RiskDetailPage(props: Props) {
|
||||
connections: [connectionId],
|
||||
},
|
||||
onSuccess() {
|
||||
const risksUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
|
||||
: `/organizations/${organizationId}/risks`;
|
||||
void navigate(risksUrl);
|
||||
void navigate(`/organizations/${organizationId}/risks`);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
@@ -120,19 +107,11 @@ export default function RiskDetailPage(props: Props) {
|
||||
const controlsCount = risk.controlsInfo?.totalCount ?? 0;
|
||||
const obligationsCount = risk.obligationsInfo?.totalCount ?? 0;
|
||||
|
||||
const risksUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
|
||||
: `/organizations/${organizationId}/risks`;
|
||||
|
||||
const baseTabUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${riskId}`
|
||||
: `/organizations/${organizationId}/risks/${riskId}`;
|
||||
const risksUrl = `/organizations/${organizationId}/risks`;
|
||||
const baseTabUrl = `/organizations/${organizationId}/risks/${riskId}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Breadcrumb
|
||||
@@ -146,56 +125,50 @@ export default function RiskDetailPage(props: Props) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex gap-2">
|
||||
{risk.canUpdate && (
|
||||
<FormRiskDialog
|
||||
trigger={(
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
risk={{ id: riskId, ...risk }}
|
||||
/>
|
||||
)}
|
||||
{risk.canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{risk.canUpdate && (
|
||||
<FormRiskDialog
|
||||
trigger={(
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
risk={{ id: riskId, ...risk }}
|
||||
/>
|
||||
)}
|
||||
{risk.canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageHeader title={risk.name} description={risk.description} />
|
||||
<Tabs>
|
||||
<TabLink to={`${baseTabUrl}/overview`}>{__("Overview")}</TabLink>
|
||||
{!isSnapshotMode && (
|
||||
<>
|
||||
<TabLink to={`${baseTabUrl}/measures`}>
|
||||
{__("Measures")}
|
||||
<TabBadge>{measuresCount}</TabBadge>
|
||||
</TabLink>
|
||||
<TabLink to={`${baseTabUrl}/documents`}>
|
||||
{__("Documents")}
|
||||
<TabBadge>{documentsCount}</TabBadge>
|
||||
</TabLink>
|
||||
<TabLink to={`${baseTabUrl}/controls`}>
|
||||
{__("Controls")}
|
||||
<TabBadge>{controlsCount}</TabBadge>
|
||||
</TabLink>
|
||||
<TabLink to={`${baseTabUrl}/obligations`}>
|
||||
{__("Obligations")}
|
||||
<TabBadge>{obligationsCount}</TabBadge>
|
||||
</TabLink>
|
||||
</>
|
||||
)}
|
||||
<TabLink to={`${baseTabUrl}/measures`}>
|
||||
{__("Measures")}
|
||||
<TabBadge>{measuresCount}</TabBadge>
|
||||
</TabLink>
|
||||
<TabLink to={`${baseTabUrl}/documents`}>
|
||||
{__("Documents")}
|
||||
<TabBadge>{documentsCount}</TabBadge>
|
||||
</TabLink>
|
||||
<TabLink to={`${baseTabUrl}/controls`}>
|
||||
{__("Controls")}
|
||||
<TabBadge>{controlsCount}</TabBadge>
|
||||
</TabLink>
|
||||
<TabLink to={`${baseTabUrl}/obligations`}>
|
||||
{__("Obligations")}
|
||||
<TabBadge>{obligationsCount}</TabBadge>
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{ risk }} />
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
ActionDropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPageTextLine,
|
||||
IconPencil,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RisksChart,
|
||||
SeverityBadge,
|
||||
@@ -34,16 +36,16 @@ import {
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import type { PreloadedQuery } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { RiskGraphFragment$data } from "#/__generated__/core/RiskGraphFragment.graphql";
|
||||
import type { RiskGraphListQuery } from "#/__generated__/core/RiskGraphListQuery.graphql";
|
||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useDeleteRiskMutation, useRisksQuery } from "#/hooks/graph/RiskGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { NodeOf } from "#/types";
|
||||
|
||||
import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog";
|
||||
import FormRiskDialog from "./FormRiskDialog";
|
||||
|
||||
type Props = {
|
||||
@@ -53,11 +55,10 @@ type Props = {
|
||||
export default function RisksPage(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: { canCreateRisk },
|
||||
data: { canCreateRisk, canPublishRisk, risksDocument },
|
||||
connectionId,
|
||||
risks,
|
||||
...pagination
|
||||
@@ -70,7 +71,6 @@ export default function RisksPage(props: Props) {
|
||||
}) => {
|
||||
pagination.refetch(
|
||||
{
|
||||
snapshotId,
|
||||
order: {
|
||||
direction: order.direction as "ASC" | "DESC",
|
||||
field: order.field as
|
||||
@@ -90,27 +90,54 @@ export default function RisksPage(props: Props) {
|
||||
usePageTitle(__("Risks"));
|
||||
|
||||
const hasAnyAction
|
||||
= !isSnapshotMode
|
||||
&& risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
|
||||
= risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
|
||||
|
||||
const defaultApproverIds
|
||||
= risksDocument?.defaultApprovers?.map(a => a.id) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
<PageHeader
|
||||
title={__("Risks")}
|
||||
description={__(
|
||||
"Risks are potential threats to your organization. Manage them by identifying, assessing, and implementing mitigation measures.",
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode && canCreateRisk && (
|
||||
<FormRiskDialog
|
||||
connection={connectionId}
|
||||
onSuccess={() => {
|
||||
pagination.refetch({ snapshotId });
|
||||
}}
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{risksDocument && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPageTextLine}
|
||||
onClick={() => void navigate(
|
||||
`/organizations/${organizationId}/documents/${risksDocument.id}`,
|
||||
)}
|
||||
>
|
||||
{__("Document")}
|
||||
</Button>
|
||||
)}
|
||||
{canPublishRisk && (
|
||||
<PublishRiskListDialog
|
||||
organizationId={organizationId}
|
||||
defaultApproverIds={defaultApproverIds}
|
||||
onPublished={documentId => void navigate(
|
||||
`/organizations/${organizationId}/documents/${documentId}`,
|
||||
)}
|
||||
>
|
||||
<Button variant="secondary" icon={IconUpload}>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishRiskListDialog>
|
||||
)}
|
||||
{canCreateRisk && (
|
||||
<FormRiskDialog
|
||||
connection={connectionId}
|
||||
onSuccess={() => {
|
||||
pagination.refetch({});
|
||||
}}
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -167,8 +194,6 @@ type RowProps = {
|
||||
function RiskRow(props: RowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const { risk, connectionId, organizationId } = props;
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const [deleteRisk] = useDeleteRiskMutation();
|
||||
const confirm = useConfirm();
|
||||
const onDelete = () => {
|
||||
@@ -195,20 +220,15 @@ function RiskRow(props: RowProps) {
|
||||
};
|
||||
const formDialogRef = useDialogRef();
|
||||
|
||||
const riskUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${risk.id}/overview`
|
||||
: `/organizations/${organizationId}/risks/${risk.id}/overview`;
|
||||
const riskUrl = `/organizations/${organizationId}/risks/${risk.id}/overview`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isSnapshotMode && (
|
||||
<FormRiskDialog
|
||||
ref={formDialogRef}
|
||||
risk={risk}
|
||||
connection={connectionId}
|
||||
/>
|
||||
)}
|
||||
<FormRiskDialog
|
||||
ref={formDialogRef}
|
||||
risk={risk}
|
||||
connection={connectionId}
|
||||
/>
|
||||
<Tr to={riskUrl}>
|
||||
<Td>{risk.name}</Td>
|
||||
<Td>{risk.category}</Td>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconSend,
|
||||
IconUpload,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PublishRiskListDialogMutation } from "#/__generated__/core/PublishRiskListDialogMutation.graphql";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
|
||||
const publishMutation = graphql`
|
||||
mutation PublishRiskListDialogMutation(
|
||||
$input: PublishRiskListInput!
|
||||
) {
|
||||
publishRiskList(input: $input) {
|
||||
documentEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
defaultApproverIds?: string[];
|
||||
onPublished?: (documentId: string) => void;
|
||||
};
|
||||
|
||||
export function PublishRiskListDialog({
|
||||
children,
|
||||
organizationId,
|
||||
defaultApproverIds,
|
||||
onPublished,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const schema = useMemo(() =>
|
||||
z.object({
|
||||
approverIds: z.array(z.string()),
|
||||
}), []);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
approverIds: defaultApproverIds ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const [publish, isPublishing]
|
||||
= useMutation<PublishRiskListDialogMutation>(publishMutation);
|
||||
|
||||
const approverIds = watch("approverIds");
|
||||
const hasApprovers = approverIds.length > 0;
|
||||
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
publish({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
|
||||
},
|
||||
},
|
||||
onCompleted(response) {
|
||||
const documentId = response.publishRiskList?.documentEdge?.node?.id;
|
||||
if (documentId) {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: hasApprovers
|
||||
? __("Approval requested successfully.")
|
||||
: __("Risks published successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
onPublished?.(documentId);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to publish risks"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="max-w-xl"
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={__("Publish Risks")}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
|
||||
</p>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={hasApprovers ? IconSend : IconUpload}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{hasApprovers ? __("Request approval") : __("Publish")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { getSnapshotTypeUrlPath } from "@probo/helpers";
|
||||
import { useEffect } from "react";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import type { SnapshotGraphNodeQuery } from "#/__generated__/core/SnapshotGraphNodeQuery.graphql";
|
||||
import { PageError } from "#/components/PageError";
|
||||
import { snapshotNodeQuery } from "#/hooks/graph/SnapshotGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<SnapshotGraphNodeQuery>;
|
||||
};
|
||||
|
||||
export default function SnapshotDetailPage({ queryRef }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { snapshotId } = useParams();
|
||||
const data = usePreloadedQuery(snapshotNodeQuery, queryRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.node || !data.node.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = data.node;
|
||||
const snapshotType = snapshot.type;
|
||||
const urlPath = getSnapshotTypeUrlPath(snapshotType);
|
||||
|
||||
void navigate(
|
||||
`/organizations/${organizationId}/snapshots/${snapshotId}${urlPath}`,
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}, [data.node, navigate, organizationId, snapshotId]);
|
||||
|
||||
if (!data.node || !data.node.type) {
|
||||
return <PageError />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
getSnapshotTypeLabel,
|
||||
getSnapshotTypeUrlPath,
|
||||
} from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
useFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { SnapshotGraphListQuery } from "#/__generated__/core/SnapshotGraphListQuery.graphql";
|
||||
import type {
|
||||
SnapshotsPageFragment$data,
|
||||
SnapshotsPageFragment$key,
|
||||
} from "#/__generated__/core/SnapshotsPageFragment.graphql";
|
||||
import { snapshotsQuery, useDeleteSnapshot } from "#/hooks/graph/SnapshotGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { NodeOf } from "#/types";
|
||||
|
||||
import SnapshotFormDialog from "./dialog/SnapshotFormDialog";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<SnapshotGraphListQuery>;
|
||||
};
|
||||
|
||||
const snapshotsFragment = graphql`
|
||||
fragment SnapshotsPageFragment on Organization {
|
||||
snapshots(first: 100)
|
||||
@connection(key: "SnapshotsGraphListQuery__snapshots") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
type
|
||||
createdAt
|
||||
canDelete: permission(action: "core:snapshot:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function SnapshotsPage(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const organization = usePreloadedQuery(
|
||||
snapshotsQuery,
|
||||
props.queryRef,
|
||||
).organization;
|
||||
const data = useFragment<SnapshotsPageFragment$key>(
|
||||
snapshotsFragment,
|
||||
organization,
|
||||
);
|
||||
const connectionId = data.snapshots.__id;
|
||||
const snapshots = data.snapshots.edges.map(edge => edge.node);
|
||||
usePageTitle(__("Snapshots"));
|
||||
|
||||
const hasAnyAction = snapshots.some(({ canDelete }) => canDelete);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Snapshots")}
|
||||
description={__(
|
||||
"Snapshots capture point-in-time views of your organization's compliance state. Create snapshots to track progress over time.",
|
||||
)}
|
||||
>
|
||||
{organization.canCreateSnapshot && (
|
||||
<SnapshotFormDialog connection={connectionId}>
|
||||
<Button variant="primary" icon={IconPlusLarge}>
|
||||
{__("New snapshot")}
|
||||
</Button>
|
||||
</SnapshotFormDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{snapshots.length > 0
|
||||
? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Type")}</Th>
|
||||
<Th>{__("Description")}</Th>
|
||||
<Th>{__("Created")}</Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{snapshots.map(snapshot => (
|
||||
<SnapshotRow
|
||||
key={snapshot.id}
|
||||
snapshot={snapshot}
|
||||
connectionId={connectionId}
|
||||
organizationId={organizationId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)
|
||||
: (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-txt-secondary mb-2">
|
||||
{__("No snapshots yet")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-6">
|
||||
{__("Create your first snapshot to get started")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SnapshotRowProps = {
|
||||
snapshot: NodeOf<SnapshotsPageFragment$data["snapshots"]>;
|
||||
connectionId: string;
|
||||
organizationId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function SnapshotRow(props: SnapshotRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const deleteSnapshot = useDeleteSnapshot(props.snapshot, props.connectionId);
|
||||
const typePath = getSnapshotTypeUrlPath(props.snapshot.type);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
to={`/organizations/${props.organizationId}/snapshots/${props.snapshot.id}${typePath}`}
|
||||
>
|
||||
<Td className="font-medium">{props.snapshot.name}</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral">
|
||||
{getSnapshotTypeLabel(__, props.snapshot.type)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="text-txt-secondary">
|
||||
{props.snapshot.description || __("No description")}
|
||||
</Td>
|
||||
<Td className="text-txt-tertiary">
|
||||
{formatDate(props.snapshot.createdAt)}
|
||||
</Td>
|
||||
{props.hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{props.snapshot.canDelete && (
|
||||
<DropdownItem
|
||||
onClick={deleteSnapshot}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { snapshotTypes } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Input,
|
||||
Label,
|
||||
PropertyRow,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { SnapshotTypeOptions } from "#/components/form/SnapshotTypeOptions";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const snapshotCreateMutation = graphql`
|
||||
mutation SnapshotFormDialogCreateMutation(
|
||||
$input: CreateSnapshotInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createSnapshot(input: $input) {
|
||||
snapshotEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
type
|
||||
createdAt
|
||||
canDelete: permission(action: "core:snapshot:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const snapshotSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name is required" }),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(snapshotTypes),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
connection?: string;
|
||||
};
|
||||
|
||||
export default function SnapshotFormDialog(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const organizationId = useOrganizationId();
|
||||
const [mutate] = useMutationWithToasts(snapshotCreateMutation, {
|
||||
successMessage: __("Snapshot created successfully."),
|
||||
errorMessage: __("Failed to create snapshot"),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(snapshotSchema, {
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "RISKS",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof snapshotSchema>) => {
|
||||
await mutate({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
type: data.type,
|
||||
},
|
||||
connections: props.connection ? [props.connection] : [],
|
||||
},
|
||||
});
|
||||
reset();
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={props.children}
|
||||
title={<Breadcrumb items={[__("Snapshots"), __("New Snapshot")]} />}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent className="grid grid-cols-[1fr_420px]">
|
||||
<div className="py-8 px-10 space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
required
|
||||
variant="title"
|
||||
placeholder={__("Snapshot name")}
|
||||
{...register("name")}
|
||||
/>
|
||||
<Textarea
|
||||
id="description"
|
||||
variant="ghost"
|
||||
autogrow
|
||||
placeholder={__("Add description (optional)")}
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
{/* Properties form */}
|
||||
<div className="py-5 px-6 bg-subtle">
|
||||
<Label>{__("Properties")}</Label>
|
||||
|
||||
<PropertyRow
|
||||
id="type"
|
||||
label={__("Type")}
|
||||
error={errors.type?.message}
|
||||
>
|
||||
<ControlledField control={control} name="type" type="select">
|
||||
<SnapshotTypeOptions />
|
||||
</ControlledField>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{__("Create snapshot")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { clsx } from "clsx";
|
||||
import { useState } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { useOutletContext } from "react-router";
|
||||
|
||||
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
|
||||
import { useVendorForm } from "#/hooks/forms/useVendorForm";
|
||||
@@ -44,13 +44,11 @@ export default function VendorCertificationsTab() {
|
||||
}>();
|
||||
const { __ } = useTranslate();
|
||||
const { control, handleSubmit } = useVendorForm(vendor);
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={!isSnapshotMode && vendor.canUpdate
|
||||
onSubmit={vendor.canUpdate
|
||||
? e => void handleSubmit(e)
|
||||
: undefined}
|
||||
>
|
||||
@@ -62,12 +60,12 @@ export default function VendorCertificationsTab() {
|
||||
<Certifications
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? []}
|
||||
readOnly={isSnapshotMode || !vendor.canUpdate}
|
||||
readOnly={!vendor.canUpdate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
{!isSnapshotMode && vendor.canUpdate && (
|
||||
{vendor.canUpdate && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">{__("Update vendor")}</Button>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@probo/ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
|
||||
@@ -110,8 +110,6 @@ export default function VendorComplianceTab() {
|
||||
const connectionId = data.complianceReports.__id;
|
||||
const reports = data.complianceReports.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
usePageTitle(vendor.name + " - " + __("Compliance reports"));
|
||||
|
||||
return (
|
||||
@@ -120,7 +118,7 @@ export default function VendorComplianceTab() {
|
||||
title={__("Compliance reports")}
|
||||
description={__("Track vendor compliance certifications and reports.")}
|
||||
>
|
||||
{!isSnapshotMode && vendor.canUploadComplianceReport && (
|
||||
{vendor.canUploadComplianceReport && (
|
||||
<UploadComplianceReportDialog
|
||||
vendorId={vendor.id}
|
||||
connectionId={connectionId}
|
||||
@@ -139,7 +137,7 @@ export default function VendorComplianceTab() {
|
||||
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
|
||||
<Th>{__("Valid until")}</Th>
|
||||
<Th>{__("File size")}</Th>
|
||||
{!isSnapshotMode && reports.length > 0 && <Th>{__("Actions")}</Th>}
|
||||
{reports.length > 0 && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -148,7 +146,6 @@ export default function VendorComplianceTab() {
|
||||
key={report.id}
|
||||
reportKey={report}
|
||||
connectionId={connectionId}
|
||||
isSnapshotMode={isSnapshotMode}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -160,7 +157,6 @@ export default function VendorComplianceTab() {
|
||||
type ReportRowProps = {
|
||||
reportKey: VendorComplianceTabFragment_report$key;
|
||||
connectionId: string;
|
||||
isSnapshotMode: boolean;
|
||||
};
|
||||
|
||||
function ReportRow(props: ReportRowProps) {
|
||||
@@ -203,33 +199,31 @@ function ReportRow(props: ReportRowProps) {
|
||||
<Td>{formatDate(report.reportDate)}</Td>
|
||||
<Td>{formatDate(report.validUntil)}</Td>
|
||||
<Td>{fileSize(__, report.file?.size ?? 0)}</Td>
|
||||
{!props.isSnapshotMode && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{report.file?.downloadUrl && (
|
||||
<DropdownItem
|
||||
icon={IconArrowDown}
|
||||
onClick={() =>
|
||||
downloadFile(
|
||||
report.file!.downloadUrl,
|
||||
report.file!.fileName,
|
||||
)}
|
||||
>
|
||||
{__("Download")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{report.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{report.file?.downloadUrl && (
|
||||
<DropdownItem
|
||||
icon={IconArrowDown}
|
||||
onClick={() =>
|
||||
downloadFile(
|
||||
report.file!.downloadUrl,
|
||||
report.file!.fileName,
|
||||
)}
|
||||
>
|
||||
{__("Download")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{report.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@probo/ui";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { VendorContactsListQuery } from "#/__generated__/core/VendorContactsListQuery.graphql";
|
||||
@@ -112,8 +112,6 @@ export default function VendorContactsTab() {
|
||||
const connectionId = data.contacts.__id;
|
||||
const contacts = data.contacts.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const [editingContact, setEditingContact]
|
||||
= useState<VendorContactsTabFragment_contact$data | null>(null);
|
||||
const hasAnyAction = contacts.some(
|
||||
@@ -128,7 +126,7 @@ export default function VendorContactsTab() {
|
||||
title={__("Contacts")}
|
||||
description={__("Manage vendor contacts and their information.")}
|
||||
>
|
||||
{!isSnapshotMode && vendor.canCreateContact && (
|
||||
{vendor.canCreateContact && (
|
||||
<CreateContactDialog vendorId={vendor.id} connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
|
||||
</CreateContactDialog>
|
||||
@@ -144,7 +142,7 @@ export default function VendorContactsTab() {
|
||||
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
|
||||
<Th>{__("Phone")}</Th>
|
||||
<Th>{__("Role")}</Th>
|
||||
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -154,13 +152,12 @@ export default function VendorContactsTab() {
|
||||
contactKey={contact}
|
||||
connectionId={connectionId}
|
||||
onEdit={setEditingContact}
|
||||
isSnapshotMode={isSnapshotMode}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
|
||||
{editingContact && !isSnapshotMode && editingContact.canUpdate && (
|
||||
{editingContact && editingContact.canUpdate && (
|
||||
<EditContactDialog
|
||||
contactId={editingContact.id}
|
||||
contact={editingContact}
|
||||
@@ -175,7 +172,6 @@ type ContactRowProps = {
|
||||
contactKey: VendorContactsTabFragment_contact$key;
|
||||
connectionId: string;
|
||||
onEdit: (contact: VendorContactsTabFragment_contact$data) => void;
|
||||
isSnapshotMode: boolean;
|
||||
};
|
||||
|
||||
function ContactRow(props: ContactRowProps) {
|
||||
@@ -245,7 +241,7 @@ function ContactRow(props: ContactRowProps) {
|
||||
)}
|
||||
</Td>
|
||||
<Td>{contact.role || __("—")}</Td>
|
||||
{!props.isSnapshotMode && hasAnyAction && (
|
||||
{hasAnyAction && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{contact.canUpdate && (
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import type { VendorCategory } from "@probo/vendors";
|
||||
import { useMemo } from "react";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { useOutletContext } from "react-router";
|
||||
|
||||
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
|
||||
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
|
||||
@@ -112,8 +112,6 @@ export default function VendorOverviewTab() {
|
||||
{ value: "VERSION_CONTROL", label: __("Version Control") },
|
||||
];
|
||||
const organizationId = useOrganizationId();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -158,11 +156,11 @@ export default function VendorOverviewTab() {
|
||||
|
||||
usePageTitle(vendor.name + " - " + __("Overview"));
|
||||
|
||||
const isFormDisabled = isSubmitting || isSnapshotMode || !vendor.canUpdate;
|
||||
const isFormDisabled = isSubmitting || !vendor.canUpdate;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={isSnapshotMode || !vendor.canUpdate
|
||||
onSubmit={!vendor.canUpdate
|
||||
? undefined
|
||||
: e => void handleSubmit(e)}
|
||||
className="space-y-12"
|
||||
@@ -330,7 +328,7 @@ export default function VendorOverviewTab() {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</Button>
|
||||
{!isSnapshotMode && businessAssociateAgreement.canUpdate && (
|
||||
{businessAssociateAgreement.canUpdate && (
|
||||
<EditBusinessAssociateAgreementDialog
|
||||
vendorId={vendor.id}
|
||||
agreement={{
|
||||
@@ -342,7 +340,7 @@ export default function VendorOverviewTab() {
|
||||
<Button variant="quaternary" icon={IconPencil} />
|
||||
</EditBusinessAssociateAgreementDialog>
|
||||
)}
|
||||
{!isSnapshotMode && businessAssociateAgreement.canDelete && (
|
||||
{businessAssociateAgreement.canDelete && (
|
||||
<DeleteBusinessAssociateAgreementDialog
|
||||
vendorId={vendor.id}
|
||||
fileName={businessAssociateAgreement.fileName}
|
||||
@@ -354,8 +352,7 @@ export default function VendorOverviewTab() {
|
||||
</>
|
||||
)
|
||||
: (
|
||||
!isSnapshotMode
|
||||
&& vendor.canUploadBAA && (
|
||||
vendor.canUploadBAA && (
|
||||
<UploadBusinessAssociateAgreementDialog
|
||||
vendorId={vendor.id}
|
||||
onSuccess={() => window.location.reload()}
|
||||
@@ -405,7 +402,7 @@ export default function VendorOverviewTab() {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</Button>
|
||||
{!isSnapshotMode && dataPrivacyAgreement.canUpdate && (
|
||||
{dataPrivacyAgreement.canUpdate && (
|
||||
<EditDataPrivacyAgreementDialog
|
||||
vendorId={vendor.id}
|
||||
agreement={{
|
||||
@@ -417,7 +414,7 @@ export default function VendorOverviewTab() {
|
||||
<Button variant="quaternary" icon={IconPencil} />
|
||||
</EditDataPrivacyAgreementDialog>
|
||||
)}
|
||||
{!isSnapshotMode && dataPrivacyAgreement.canDelete && (
|
||||
{dataPrivacyAgreement.canDelete && (
|
||||
<DeleteDataPrivacyAgreementDialog
|
||||
vendorId={vendor.id}
|
||||
fileName={dataPrivacyAgreement.fileName}
|
||||
@@ -429,8 +426,7 @@ export default function VendorOverviewTab() {
|
||||
</>
|
||||
)
|
||||
: (
|
||||
!isSnapshotMode
|
||||
&& vendor.canUploadDPA && (
|
||||
vendor.canUploadDPA && (
|
||||
<UploadDataPrivacyAgreementDialog
|
||||
vendorId={vendor.id}
|
||||
onSuccess={() => window.location.reload()}
|
||||
@@ -447,15 +443,13 @@ export default function VendorOverviewTab() {
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex justify-end">
|
||||
{vendor.canUpdate && (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{__("Update vendor")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
{vendor.canUpdate && (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{__("Update vendor")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { clsx } from "clsx";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
|
||||
@@ -96,8 +96,6 @@ export default function VendorRiskAssessmentTab() {
|
||||
>(riskAssessmentsFragment, vendor);
|
||||
const assessments = data.riskAssessments.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
usePageTitle(vendor.name + " - " + __("Risk Assessments"));
|
||||
@@ -106,7 +104,7 @@ export default function VendorRiskAssessmentTab() {
|
||||
return (
|
||||
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
|
||||
{__("No risk assessments found")}
|
||||
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
|
||||
{vendor.canCreateRiskAssessment && (
|
||||
<CreateRiskAssessmentDialog
|
||||
vendorId={vendor.id}
|
||||
connection={data.riskAssessments.__id}
|
||||
@@ -136,7 +134,7 @@ export default function VendorRiskAssessmentTab() {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
|
||||
{vendor.canCreateRiskAssessment && (
|
||||
<CreateRiskAssessmentDialog
|
||||
vendorId={vendor.id}
|
||||
connection={data.riskAssessments.__id}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@probo/ui";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
|
||||
@@ -110,8 +110,6 @@ export default function VendorServicesTab() {
|
||||
const connectionId = data.services.__id;
|
||||
const services = data.services.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const [editingService, setEditingService]
|
||||
= useState<VendorServicesTabFragment_service$data | null>(null);
|
||||
const hasAnyAction = services.some(
|
||||
@@ -126,7 +124,7 @@ export default function VendorServicesTab() {
|
||||
title={__("Services")}
|
||||
description={__("Manage services provided by this vendor.")}
|
||||
>
|
||||
{!isSnapshotMode && vendor.canCreateService && (
|
||||
{vendor.canCreateService && (
|
||||
<CreateServiceDialog vendorId={vendor.id} connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
|
||||
</CreateServiceDialog>
|
||||
@@ -140,7 +138,7 @@ export default function VendorServicesTab() {
|
||||
<Tr>
|
||||
<SortableTh field="NAME">{__("Name")}</SortableTh>
|
||||
<Th>{__("Description")}</Th>
|
||||
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -150,13 +148,12 @@ export default function VendorServicesTab() {
|
||||
serviceKey={service}
|
||||
connectionId={connectionId}
|
||||
onEdit={setEditingService}
|
||||
isSnapshotMode={isSnapshotMode}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
|
||||
{editingService && !isSnapshotMode && editingService.canUpdate && (
|
||||
{editingService && editingService.canUpdate && (
|
||||
<EditServiceDialog
|
||||
serviceId={editingService.id}
|
||||
service={editingService}
|
||||
@@ -171,7 +168,6 @@ type ServiceRowProps = {
|
||||
serviceKey: VendorServicesTabFragment_service$key;
|
||||
connectionId: string;
|
||||
onEdit: (service: VendorServicesTabFragment_service$data) => void;
|
||||
isSnapshotMode: boolean;
|
||||
};
|
||||
|
||||
function ServiceRow(props: ServiceRowProps) {
|
||||
@@ -213,7 +209,7 @@ function ServiceRow(props: ServiceRowProps) {
|
||||
<Tr>
|
||||
<Td>{service.name}</Td>
|
||||
<Td>{service.description || __("—")}</Td>
|
||||
{!props.isSnapshotMode && hasAnyAction && (
|
||||
{hasAnyAction && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{service.canUpdate && (
|
||||
|
||||
Reference in New Issue
Block a user