Refactor risks frontend to follow page arborescence conventions

- Split layout's monolithic query: each child route now owns its query
  via its own *PageLoader + *Page (overview/measures/documents/controls/
  obligations/scenarios).
- Rename tabs/RiskXTab.tsx -> <resource>/RiskXPage.tsx; drop UI-named
  tabs/ and dialogs/ folders.
- Move FormRiskDialog, PublishRiskListDialog and the extracted RiskRow
  into _components/ as named exports.
- Move risk form fragment ownership into FormRiskDialog: define
  FormRiskDialog_risk in the dialog and pass plain data to useRiskForm
  so spread sites colocate cleanly without disable comments.
- Align risk components with react-components.md naming: rename Relay
  fragments to ComponentName_typeName and replace 'type Props' aliases
  with 'interface ComponentNameProps'.
- Guard risk pages against null node and mutation errors: add
  __typename narrowing to every node(id) query, surface deleteRisk
  errors via toast, and resolve the confirm promise on error so the
  dialog no longer hangs.
- Replace deprecated useMutationWithToasts (FormRiskDialog) and
  useLazyLoadQuery (LinkScenarioDialog) with useMutation+useToast and
  useQueryLoader+usePreloadedQuery.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-28 15:02:09 +02:00
parent 361fc0f7bc
commit c78a713adf
21 changed files with 856 additions and 514 deletions

View File

@@ -12,51 +12,29 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type {
useRiskFormFragment$data,
useRiskFormFragment$key,
} from "#/__generated__/core/useRiskFormFragment.graphql";
import type { FormRiskDialog_risk$data } from "#/__generated__/core/FormRiskDialog_risk.graphql";
import { useFormWithSchema } from "../useFormWithSchema";
const RiskFragment = graphql`
fragment useRiskFormFragment on Risk {
id
# eslint-disable-next-line relay/unused-fields
name
# eslint-disable-next-line relay/unused-fields
category
description
# eslint-disable-next-line relay/unused-fields
treatment
# eslint-disable-next-line relay/unused-fields
inherentLikelihood
# eslint-disable-next-line relay/unused-fields
inherentImpact
# eslint-disable-next-line relay/unused-fields
residualLikelihood
# eslint-disable-next-line relay/unused-fields
residualImpact
# eslint-disable-next-line relay/unused-fields
inherentRiskScore
# eslint-disable-next-line relay/unused-fields
residualRiskScore
# eslint-disable-next-line relay/unused-fields
note
owner {
id
}
}
`;
export type RiskNode = Pick<
FormRiskDialog_risk$data,
| "id"
| "name"
| "category"
| "description"
| "treatment"
| "inherentLikelihood"
| "inherentImpact"
| "residualLikelihood"
| "residualImpact"
| "inherentRiskScore"
| "residualRiskScore"
| "note"
| "owner"
>;
export type RiskNode = useRiskFormFragment$data;
export type RiskKey = useRiskFormFragment$key & { id: string };
// Export the schema so it can be used elsewhere
export const riskSchema = z.object({
category: z.string().min(1, "Category is required"),
name: z.string().min(1, "Name is required"),
@@ -70,8 +48,7 @@ export const riskSchema = z.object({
note: z.string().optional(),
});
export const useRiskForm = (riskKey?: RiskKey) => {
const risk = useFragment(RiskFragment, riskKey);
export const useRiskForm = (risk?: RiskNode) => {
return useFormWithSchema(riskSchema, {
defaultValues: risk
? {

View File

@@ -41,20 +41,17 @@ import type { RiskDetailLayoutQuery } from "#/__generated__/core/RiskDetailLayou
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { RisksConnectionKey } from "#/pages/organizations/risks/RisksPage";
import FormRiskDialog from "./FormRiskDialog";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
import { FormRiskDialog } from "./_components/FormRiskDialog";
export const riskDetailLayoutQuery = graphql`
query RiskDetailLayoutQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
id
name
description
treatment
owner {
id
fullName
}
note
@@ -77,31 +74,7 @@ export const riskDetailLayoutQuery = graphql`
}
canUpdate: permission(action: "core:risk:update")
canDelete: permission(action: "core:risk:delete")
canCreateDocumentMapping: permission(
action: "core:risk:create-document-mapping"
)
canDeleteDocumentMapping: permission(
action: "core:risk:delete-document-mapping"
)
canCreateMeasureMapping: permission(
action: "core:risk:create-measure-mapping"
)
canDeleteMeasureMapping: permission(
action: "core:risk:delete-measure-mapping"
)
canCreateObligationMapping: permission(
action: "core:risk:create-obligation-mapping"
)
canDeleteObligationMapping: permission(
action: "core:risk:delete-obligation-mapping"
)
...useRiskFormFragment
...RiskOverviewTabFragment
...RiskMeasuresTabFragment
...RiskDocumentsTabFragment
...RiskControlsTabFragment
...RiskObligationsTabFragment
...RiskScenariosPageFragment
...FormRiskDialog_risk
}
}
}
@@ -118,11 +91,11 @@ const deleteRiskMutation = graphql`
}
`;
type Props = {
interface RiskDetailLayoutProps {
queryRef: PreloadedQuery<RiskDetailLayoutQuery>;
};
}
export default function RiskDetailLayout(props: Props) {
export default function RiskDetailLayout(props: RiskDetailLayoutProps) {
const { riskId } = useParams<{
riskId: string;
}>();
@@ -134,14 +107,15 @@ export default function RiskDetailLayout(props: Props) {
}
const { __ } = useTranslate();
const { node: risk } = usePreloadedQuery(
riskDetailLayoutQuery,
props.queryRef,
);
const data = usePreloadedQuery(riskDetailLayoutQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const risk = data.node;
const [deleteRisk] = useMutation<RiskDetailLayoutDeleteMutation>(deleteRiskMutation);
usePageTitle(risk.name ?? "Risk detail");
usePageTitle(risk.name);
const confirm = useConfirm();
const onDelete = () => {
@@ -188,7 +162,6 @@ export default function RiskDetailLayout(props: Props) {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center mb-4">
<Breadcrumb
items={[
@@ -209,7 +182,7 @@ export default function RiskDetailLayout(props: Props) {
{__("Edit")}
</Button>
)}
risk={{ id: riskId, ...risk }}
risk={risk}
/>
)}
{risk.canDelete && (
@@ -251,7 +224,7 @@ export default function RiskDetailLayout(props: Props) {
</TabLink>
</Tabs>
<Outlet context={{ risk }} />
<Outlet />
<Drawer>
<PropertyRow label={__("Owner")}>

View File

@@ -12,50 +12,37 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { getTreatment, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Button,
DropdownItem,
IconPageTextLine,
IconPencil,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
RisksChart,
SeverityBadge,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
useDialogRef,
} from "@probo/ui";
import {
graphql,
type PreloadedQuery,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useNavigate } from "react-router";
import type { RisksPageDeleteMutation } from "#/__generated__/core/RisksPageDeleteMutation.graphql";
import type { RisksPageFragment$data, RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql";
import type { RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql";
import type { RisksPageQuery } from "#/__generated__/core/RisksPageQuery.graphql";
import type { RisksPageRefetchQuery } from "#/__generated__/core/RisksPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog";
import FormRiskDialog from "./FormRiskDialog";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
import { FormRiskDialog } from "./_components/FormRiskDialog";
import { PublishRiskListDialog } from "./_components/PublishRiskListDialog";
import { RiskRow } from "./_components/RiskRow";
export const risksPageQuery = graphql`
query RisksPageQuery($organizationId: ID!) {
@@ -83,8 +70,6 @@ const risksFragment = graphql`
canPublishRisk: permission(action: "core:risk:publish")
risksDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
@@ -101,45 +86,26 @@ const risksFragment = graphql`
node {
id
name
category
treatment
owner {
id
fullName
}
inherentLikelihood
inherentImpact
residualLikelihood
residualImpact
inherentRiskScore
residualRiskScore
canUpdate: permission(action: "core:risk:update")
canDelete: permission(action: "core:risk:delete")
...useRiskFormFragment
...RiskRow_risk
}
}
}
}
`;
const deleteRiskMutation = graphql`
mutation RisksPageDeleteMutation(
$input: DeleteRiskInput!
$connections: [ID!]!
) {
deleteRisk(input: $input) {
deletedRiskId @deleteEdge(connections: $connections)
}
}
`;
export const RisksConnectionKey = "RisksPage_risks";
type Props = {
interface RisksPageProps {
queryRef: PreloadedQuery<RisksPageQuery>;
};
}
export default function RisksPage(props: Props) {
export default function RisksPage(props: RisksPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
@@ -156,6 +122,22 @@ export default function RisksPage(props: Props) {
const risks = fragmentData.risks?.edges.map(edge => edge.node) ?? [];
const connectionId = fragmentData.risks.__id;
const chartRisks = risks.map(({
id,
name,
inherentLikelihood,
inherentImpact,
residualLikelihood,
residualImpact,
}) => ({
id,
name,
inherentLikelihood,
inherentImpact,
residualLikelihood,
residualImpact,
}));
const refetch = ({
order,
}: {
@@ -181,8 +163,9 @@ export default function RisksPage(props: Props) {
usePageTitle(__("Risks"));
const hasAnyAction
= risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
const hasAnyAction = risks.some(
({ canDelete, canUpdate }) => canUpdate || canDelete,
);
const defaultApproverIds
= risksDocument?.defaultApprovers?.map(a => a.id) ?? [];
@@ -236,12 +219,12 @@ export default function RisksPage(props: Props) {
<RisksChart
organizationId={organizationId}
type="inherent"
risks={risks}
risks={chartRisks}
/>
<RisksChart
organizationId={organizationId}
type="residual"
risks={risks}
risks={chartRisks}
/>
</div>
<SortableTable {...pagination} refetch={refetch}>
@@ -261,12 +244,11 @@ export default function RisksPage(props: Props) {
</Tr>
</Thead>
<Tbody>
{risks?.map(risk => (
{risks.map(risk => (
<RiskRow
risk={risk}
key={risk.id}
riskKey={risk}
connectionId={connectionId}
organizationId={organizationId}
hasAnyAction={hasAnyAction}
/>
))}
@@ -275,88 +257,3 @@ export default function RisksPage(props: Props) {
</div>
);
}
type RowProps = {
risk: NodeOf<RisksPageFragment$data["risks"]>;
connectionId: string;
organizationId: string;
hasAnyAction: boolean;
};
function RiskRow(props: RowProps) {
const { __ } = useTranslate();
const { risk, connectionId, organizationId } = props;
const [deleteRisk] = useMutation<RisksPageDeleteMutation>(deleteRiskMutation);
const confirm = useConfirm();
const onDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
void deleteRisk({
variables: {
input: { riskId: risk.id },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
}),
{
message: sprintf(
__(
"This will permanently delete the risk \"%s\". This action cannot be undone.",
),
risk.name,
),
},
);
};
const formDialogRef = useDialogRef();
const riskUrl = `/organizations/${organizationId}/risks/${risk.id}/overview`;
return (
<>
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
<Tr to={riskUrl}>
<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>{risk.owner?.fullName || __("Unassigned")}</Td>
{props.hasAnyAction && (
<Td noLink className="text-end">
<ActionDropdown>
{risk.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
)}
{risk.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
</>
);
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { getRiskImpacts, getRiskLikelihoods } from "@probo/helpers";
import { formatError, getRiskImpacts, getRiskLikelihoods, type GraphQLError } from "@probo/helpers";
import { useToggle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
@@ -30,11 +30,14 @@ import {
Select,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode, useMemo } from "react";
import type { FieldErrors } from "react-hook-form";
import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { FormRiskDialog_risk$key } from "#/__generated__/core/FormRiskDialog_risk.graphql";
import type { FormRiskDialogMutation } from "#/__generated__/core/FormRiskDialogMutation.graphql";
import type { FormRiskDialogUpdateRiskMutation } from "#/__generated__/core/FormRiskDialogUpdateRiskMutation.graphql";
import {
@@ -45,20 +48,18 @@ import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import {
type RiskData,
type RiskForm,
type RiskKey,
useRiskForm,
} from "#/hooks/forms/useRiskForm";
import { useFetchQuery } from "#/hooks/useFetchQuery";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = {
interface FormRiskDialogProps {
trigger?: ReactNode;
risk?: RiskKey;
risk?: FormRiskDialog_risk$key;
connection?: string;
ref?: ReturnType<typeof useDialogRef>;
onSuccess?: () => void;
};
}
type RiskTemplate = {
category: string;
@@ -66,6 +67,26 @@ type RiskTemplate = {
description: string;
};
const formRiskFragment = graphql`
fragment FormRiskDialog_risk on Risk {
id
name
category
description
treatment
inherentLikelihood
inherentImpact
residualLikelihood
residualImpact
inherentRiskScore
residualRiskScore
note
owner {
id
}
}
`;
const createRiskMutation = graphql`
mutation FormRiskDialogMutation(
$input: CreateRiskInput!
@@ -74,7 +95,7 @@ const createRiskMutation = graphql`
createRisk(input: $input) {
riskEdge @prependEdge(connections: $connections) {
node {
...useRiskFormFragment
...FormRiskDialog_risk
}
}
}
@@ -85,44 +106,60 @@ const updateRiskMutation = graphql`
mutation FormRiskDialogUpdateRiskMutation($input: UpdateRiskInput!) {
updateRisk(input: $input) {
risk {
...useRiskFormFragment
...FormRiskDialog_risk
}
}
}
`;
/**
* Dialog to create or update a risk
*/
export default function FormRiskDialog({
export function FormRiskDialog({
trigger,
risk,
risk: riskKey,
connection,
ref: refProps,
onSuccess,
}: Props) {
}: FormRiskDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const ref = refProps ?? dialogRef;
const risk = useFragment(formRiskFragment, riskKey ?? null);
const formDefaults = risk
? {
id: risk.id,
name: risk.name,
category: risk.category,
description: risk.description,
treatment: risk.treatment,
inherentLikelihood: risk.inherentLikelihood,
inherentImpact: risk.inherentImpact,
residualLikelihood: risk.residualLikelihood,
residualImpact: risk.residualImpact,
inherentRiskScore: risk.inherentRiskScore,
residualRiskScore: risk.residualRiskScore,
note: risk.note,
owner: risk.owner,
}
: undefined;
const { control, handleSubmit, setValue, register, watch, formState, reset }
= useRiskForm(risk);
= useRiskForm(formDefaults);
const errors = formState.errors ?? {};
const [createRisk, isLoadingCreate]
= useMutationWithToasts<FormRiskDialogMutation>(createRiskMutation);
const [updateRisk, isLoadingUpdate]
= useMutationWithToasts<FormRiskDialogUpdateRiskMutation>(updateRiskMutation);
const isLoading = isLoadingCreate || isLoadingUpdate;
const [createRisk, isCreating]
= useMutation<FormRiskDialogMutation>(createRiskMutation);
const [updateRisk, isUpdating]
= useMutation<FormRiskDialogUpdateRiskMutation>(updateRiskMutation);
const isLoading = isCreating || isUpdating;
const onTemplateChange = (risk: RiskTemplate) => {
setValue("name", risk.name);
setValue("description", risk.description);
const onTemplateChange = (template: RiskTemplate) => {
setValue("name", template.name);
setValue("description", template.description);
};
const onSubmit = async (data: RiskData) => {
const onSubmit = (data: RiskData) => {
if (risk) {
await updateRisk({
updateRisk({
variables: {
input: {
id: risk.id,
@@ -130,15 +167,28 @@ export default function FormRiskDialog({
description: data.description || null,
},
},
successMessage: __("Risk updated successfully."),
errorMessage: __("Failed to update risk"),
onSuccess: () => {
onCompleted() {
toast({
title: __("Success"),
description: __("Risk updated successfully."),
variant: "success",
});
ref?.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update risk"),
error as GraphQLError,
),
variant: "error",
});
},
});
return;
}
await createRisk({
createRisk({
variables: {
input: {
...data,
@@ -147,13 +197,26 @@ export default function FormRiskDialog({
},
connections: [connection!],
},
successMessage: __("Risk created successfully."),
errorMessage: __("Failed to create risk"),
onSuccess: () => {
onCompleted() {
toast({
title: __("Success"),
description: __("Risk created successfully."),
variant: "success",
});
ref?.current?.close();
reset();
onSuccess?.();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to create risk"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
@@ -171,7 +234,6 @@ export default function FormRiskDialog({
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent className="grid grid-cols-[1fr_420px]">
{/* Main form */}
<div className="py-8 px-12 space-y-6">
<TemplateSelector
onChange={onTemplateChange}
@@ -209,7 +271,6 @@ export default function FormRiskDialog({
</div>
</div>
{/* Properties form */}
<div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label>

View File

@@ -25,8 +25,13 @@ import {
Input,
Spinner,
} from "@probo/ui";
import { type ReactNode, Suspense, useMemo, useState } from "react";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import { cloneElement, isValidElement, type MouseEvent, type ReactElement, type ReactNode, Suspense, useMemo, useState } from "react";
import {
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { graphql } from "relay-runtime";
import type {
@@ -71,39 +76,54 @@ const scenariosFragment = graphql`
}
`;
type Props = {
interface LinkScenarioDialogProps {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedScenarios?: { id: string }[];
onLink: (scenarioId: string) => void;
onUnlink: (scenarioId: string) => void;
};
}
export function LinkScenarioDialog({ children, ...props }: Props) {
export function LinkScenarioDialog({ children, ...props }: LinkScenarioDialogProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [queryRef, loadQuery]
= useQueryLoader<LinkScenarioDialogQuery>(scenariosQuery);
const trigger = isValidElement(children)
? cloneElement(children as ReactElement<{ onClick?: (e: MouseEvent) => void }>, {
onClick: (e: MouseEvent) => {
(children as ReactElement<{ onClick?: (e: MouseEvent) => void }>).props.onClick?.(e);
loadQuery({ organizationId }, { fetchPolicy: "network-only" });
},
})
: children;
return (
<Dialog trigger={children} title={__("Link scenarios")}>
<Dialog trigger={trigger} title={__("Link scenarios")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkScenarioDialogContent {...props} />
</Suspense>
{queryRef
? (
<Suspense fallback={<Spinner centered />}>
<LinkScenarioDialogContent queryRef={queryRef} {...props} />
</Suspense>
)
: (
<Spinner centered />
)}
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkScenarioDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const query = useLazyLoadQuery<LinkScenarioDialogQuery>(
scenariosQuery,
{
organizationId,
},
{ fetchPolicy: "network-only" },
);
type ContentProps = Omit<LinkScenarioDialogProps, "children"> & {
queryRef: PreloadedQuery<LinkScenarioDialogQuery>;
};
function LinkScenarioDialogContent(props: ContentProps) {
const query = usePreloadedQuery(scenariosQuery, props.queryRef);
const { data, loadNext, hasNext, isLoadingNext }
= usePaginationFragment<LinkScenarioDialogQuery_fragment, LinkScenarioDialogFragment$key>(
scenariosFragment,

View File

@@ -48,19 +48,19 @@ const publishMutation = graphql`
}
`;
type Props = {
interface PublishRiskListDialogProps {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
}
export function PublishRiskListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
}: PublishRiskListDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();

View File

@@ -0,0 +1,164 @@
// 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 { formatError, getTreatment, type GraphQLError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
DropdownItem,
IconPencil,
IconTrashCan,
SeverityBadge,
Td,
Tr,
useConfirm,
useDialogRef,
useToast,
} from "@probo/ui";
import { graphql, useFragment, useMutation } from "react-relay";
import type { RiskRow_risk$key } from "#/__generated__/core/RiskRow_risk.graphql";
import type { RiskRowDeleteMutation } from "#/__generated__/core/RiskRowDeleteMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { FormRiskDialog } from "./FormRiskDialog";
const riskRowFragment = graphql`
fragment RiskRow_risk on Risk {
id
name
category
treatment
owner {
id
fullName
}
inherentRiskScore
residualRiskScore
canUpdate: permission(action: "core:risk:update")
canDelete: permission(action: "core:risk:delete")
...FormRiskDialog_risk
}
`;
const deleteRiskMutation = graphql`
mutation RiskRowDeleteMutation(
$input: DeleteRiskInput!
$connections: [ID!]!
) {
deleteRisk(input: $input) {
deletedRiskId @deleteEdge(connections: $connections)
}
}
`;
interface RiskRowProps {
riskKey: RiskRow_risk$key;
connectionId: string;
hasAnyAction: boolean;
}
export function RiskRow(props: RiskRowProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const risk = useFragment(riskRowFragment, props.riskKey);
const [deleteRisk] = useMutation<RiskRowDeleteMutation>(deleteRiskMutation);
const confirm = useConfirm();
const { toast } = useToast();
const formDialogRef = useDialogRef();
const onDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
void deleteRisk({
variables: {
input: { riskId: risk.id },
connections: [props.connectionId],
},
onCompleted() {
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete risk"),
error as GraphQLError,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the risk \"%s\". This action cannot be undone.",
),
risk.name,
),
},
);
};
const riskUrl = `/organizations/${organizationId}/risks/${risk.id}/overview`;
return (
<>
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={props.connectionId}
/>
<Tr to={riskUrl}>
<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>{risk.owner?.fullName || __("Unassigned")}</Td>
{props.hasAnyAction && (
<Td noLink className="text-end">
<ActionDropdown>
{risk.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
)}
{risk.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
</>
);
}

View File

@@ -1,76 +0,0 @@
// 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 { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
DropdownItem,
IconTrashCan,
useConfirm,
} from "@probo/ui";
import { graphql, useMutation } from "react-relay";
import type { ScenarioActionsUnlinkMutation } from "#/__generated__/core/ScenarioActionsUnlinkMutation.graphql";
const unlinkMutation = graphql`
mutation ScenarioActionsUnlinkMutation(
$input: UnlinkRiskAssessmentScenarioRiskInput!
$connections: [ID!]!
) {
unlinkRiskAssessmentScenarioRisk(input: $input) {
deletedRiskAssessmentScenarioId @deleteEdge(connections: $connections)
}
}
`;
export function ScenarioActions(props: {
scenarioId: string;
riskId: string;
connectionId: string;
}) {
const { __ } = useTranslate();
const confirm = useConfirm();
const [unlinkScenario] = useMutation<ScenarioActionsUnlinkMutation>(unlinkMutation);
const handleUnlink = () => {
confirm(
() => {
unlinkScenario({
variables: {
input: {
riskAssessmentScenarioId: props.scenarioId,
riskId: props.riskId,
},
connections: [props.connectionId],
},
});
},
{
message: __("Remove this scenario from the risk?"),
},
);
};
return (
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleUnlink}
>
{__("Remove")}
</DropdownItem>
</ActionDropdown>
);
}

View File

@@ -15,16 +15,32 @@
import { useTranslate } from "@probo/i18n";
import { Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import type { ComponentProps } from "react";
import { graphql, usePaginationFragment } from "react-relay";
import { useOutletContext } from "react-router";
import {
graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import type { RiskControlsTabControlsQuery } from "#/__generated__/core/RiskControlsTabControlsQuery.graphql";
import type { RiskControlsTabFragment$key } from "#/__generated__/core/RiskControlsTabFragment.graphql";
import type { RiskControlsPage_risk$key } from "#/__generated__/core/RiskControlsPage_risk.graphql";
import type { RiskControlsPageQuery } from "#/__generated__/core/RiskControlsPageQuery.graphql";
import type { RiskControlsPageRefetchQuery } from "#/__generated__/core/RiskControlsPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
export const controlsFragment = graphql`
fragment RiskControlsTabFragment on Risk
export const riskControlsPageQuery = graphql`
query RiskControlsPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
...RiskControlsPage_risk
}
}
}
`;
const controlsFragment = graphql`
fragment RiskControlsPage_risk on Risk
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
after: { type: "CursorKey" }
@@ -33,7 +49,7 @@ export const controlsFragment = graphql`
order: { type: "ControlOrder", defaultValue: null }
filter: { type: "ControlFilter", defaultValue: null }
)
@refetchable(queryName: "RiskControlsTabControlsQuery") {
@refetchable(queryName: "RiskControlsPageRefetchQuery") {
id
controls(
first: $first
@@ -42,7 +58,7 @@ export const controlsFragment = graphql`
before: $before
orderBy: $order
filter: $filter
) @connection(key: "RiskControlsTab_controls") {
) @connection(key: "RiskControlsPage_controls") {
edges {
node {
id
@@ -57,17 +73,23 @@ export const controlsFragment = graphql`
}
}
`;
export default function RiskControlsTab() {
const { risk } = useOutletContext<{
risk: RiskControlsTabFragment$key & { id: string };
}>();
interface RiskControlsPageProps {
queryRef: PreloadedQuery<RiskControlsPageQuery>;
}
export default function RiskControlsPage(props: RiskControlsPageProps) {
const { __ } = useTranslate();
const pagination = usePaginationFragment<
RiskControlsTabControlsQuery,
RiskControlsTabFragment$key
>(controlsFragment, risk);
const controls = pagination.data.controls.edges.map(edge => edge.node);
const organizationId = useOrganizationId();
const data = usePreloadedQuery(riskControlsPageQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const pagination = usePaginationFragment<
RiskControlsPageRefetchQuery,
RiskControlsPage_risk$key
>(controlsFragment, data.node);
const controls = pagination.data.controls.edges.map(edge => edge.node);
return (
<SortableTable

View File

@@ -0,0 +1,44 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { RiskControlsPageQuery } from "#/__generated__/core/RiskControlsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import RiskControlsPage, { riskControlsPageQuery } from "./RiskControlsPage";
export default function RiskControlsPageLoader() {
const { riskId } = useParams<{ riskId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<RiskControlsPageQuery>(riskControlsPageQuery);
useEffect(() => {
if (riskId) {
loadQuery({ riskId });
}
}, [loadQuery, riskId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<RiskControlsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -12,23 +12,32 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
import type { RiskDocumentsTabFragment$key } from "#/__generated__/core/RiskDocumentsTabFragment.graphql";
import type { RiskDocumentsPageQuery } from "#/__generated__/core/RiskDocumentsPageQuery.graphql";
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const documentsFragment = graphql`
fragment RiskDocumentsTabFragment on Risk {
id
documents(first: 100) @connection(key: "Risk__documents") {
__id
edges {
node {
id
...LinkedDocumentsCardFragment
export const riskDocumentsPageQuery = graphql`
query RiskDocumentsPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
id
canCreateDocumentMapping: permission(
action: "core:risk:create-document-mapping"
)
canDeleteDocumentMapping: permission(
action: "core:risk:delete-document-mapping"
)
documents(first: 100) @connection(key: "RiskDocumentsPage_documents") {
__id
edges {
node {
id
...LinkedDocumentsCardFragment
}
}
}
}
}
@@ -36,7 +45,7 @@ export const documentsFragment = graphql`
`;
const attachDocumentMutation = graphql`
mutation RiskDocumentsTabCreateMutation(
mutation RiskDocumentsPageCreateMutation(
$input: CreateRiskDocumentMappingInput!
$connections: [ID!]!
) {
@@ -51,8 +60,8 @@ const attachDocumentMutation = graphql`
}
`;
export const detachDocumentMutation = graphql`
mutation RiskDocumentsTabDetachMutation(
const detachDocumentMutation = graphql`
mutation RiskDocumentsPageDetachMutation(
$input: DeleteRiskDocumentMappingInput!
$connections: [ID!]!
) {
@@ -62,23 +71,23 @@ export const detachDocumentMutation = graphql`
}
`;
export default function RiskDocumentsTab() {
const { risk } = useOutletContext<{
risk: RiskDetailLayoutQuery$data["node"];
}>();
const data = useFragment<RiskDocumentsTabFragment$key>(
documentsFragment,
risk,
);
const connectionId = data.documents.__id;
const documents = data.documents?.edges?.map(edge => edge.node) ?? [];
interface RiskDocumentsPageProps {
queryRef: PreloadedQuery<RiskDocumentsPageQuery>;
}
const canLinkDocument = risk.canCreateDocumentMapping;
const canUnlinkDocument = risk.canDeleteDocumentMapping;
const readOnly = !canLinkDocument && !canUnlinkDocument;
export default function RiskDocumentsPage(props: RiskDocumentsPageProps) {
const data = usePreloadedQuery(riskDocumentsPageQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const risk = data.node;
const connectionId = risk.documents.__id;
const documents = risk.documents.edges.map(edge => edge.node);
const readOnly = !risk.canCreateDocumentMapping && !risk.canDeleteDocumentMapping;
const incrementOptions = {
id: data.id,
id: risk.id,
node: "documents(first:0)",
};
const [detachDocument, isDetaching] = useMutationWithIncrement(
@@ -103,7 +112,7 @@ export default function RiskDocumentsTab() {
documents={documents}
onAttach={attachDocument}
onDetach={detachDocument}
params={{ riskId: data.id }}
params={{ riskId: risk.id }}
connectionId={connectionId}
readOnly={readOnly}
/>

View File

@@ -0,0 +1,44 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { RiskDocumentsPageQuery } from "#/__generated__/core/RiskDocumentsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import RiskDocumentsPage, { riskDocumentsPageQuery } from "./RiskDocumentsPage";
export default function RiskDocumentsPageLoader() {
const { riskId } = useParams<{ riskId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<RiskDocumentsPageQuery>(riskDocumentsPageQuery);
useEffect(() => {
if (riskId) {
loadQuery({ riskId });
}
}, [loadQuery, riskId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<RiskDocumentsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -12,23 +12,32 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
import type { RiskMeasuresTabFragment$key } from "#/__generated__/core/RiskMeasuresTabFragment.graphql";
import type { RiskMeasuresPageQuery } from "#/__generated__/core/RiskMeasuresPageQuery.graphql";
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const measuresFragment = graphql`
fragment RiskMeasuresTabFragment on Risk {
id
measures(first: 100) @connection(key: "Risk__measures") {
__id
edges {
node {
id
...LinkedMeasuresCardFragment
export const riskMeasuresPageQuery = graphql`
query RiskMeasuresPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
id
canCreateMeasureMapping: permission(
action: "core:risk:create-measure-mapping"
)
canDeleteMeasureMapping: permission(
action: "core:risk:delete-measure-mapping"
)
measures(first: 100) @connection(key: "RiskMeasuresPage_measures") {
__id
edges {
node {
id
...LinkedMeasuresCardFragment
}
}
}
}
}
@@ -36,7 +45,7 @@ export const measuresFragment = graphql`
`;
const attachMeasureMutation = graphql`
mutation RiskMeasuresTabCreateMutation(
mutation RiskMeasuresPageCreateMutation(
$input: CreateRiskMeasureMappingInput!
$connections: [ID!]!
) {
@@ -51,8 +60,8 @@ const attachMeasureMutation = graphql`
}
`;
export const detachMeasureMutation = graphql`
mutation RiskMeasuresTabDetachMutation(
const detachMeasureMutation = graphql`
mutation RiskMeasuresPageDetachMutation(
$input: DeleteRiskMeasureMappingInput!
$connections: [ID!]!
) {
@@ -62,20 +71,23 @@ export const detachMeasureMutation = graphql`
}
`;
export default function RiskMeasuresTab() {
const { risk } = useOutletContext<{
risk: RiskDetailLayoutQuery$data["node"];
}>();
const data = useFragment<RiskMeasuresTabFragment$key>(measuresFragment, risk);
const connectionId = data.measures.__id;
const measures = data.measures?.edges?.map(edge => edge.node) ?? [];
interface RiskMeasuresPageProps {
queryRef: PreloadedQuery<RiskMeasuresPageQuery>;
}
const canLinkMeasure = risk.canCreateMeasureMapping;
const canUnlinkMeasure = risk.canDeleteMeasureMapping;
const readOnly = !canLinkMeasure && !canUnlinkMeasure;
export default function RiskMeasuresPage(props: RiskMeasuresPageProps) {
const data = usePreloadedQuery(riskMeasuresPageQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const risk = data.node;
const connectionId = risk.measures.__id;
const measures = risk.measures.edges.map(edge => edge.node);
const readOnly = !risk.canCreateMeasureMapping && !risk.canDeleteMeasureMapping;
const incrementOptions = {
id: data.id,
id: risk.id,
node: "measures(first:0)",
};
const [detachMeasure, isDetaching] = useMutationWithIncrement(
@@ -100,7 +112,7 @@ export default function RiskMeasuresTab() {
measures={measures}
onAttach={attachMeasure}
onDetach={detachMeasure}
params={{ riskId: data.id }}
params={{ riskId: risk.id }}
connectionId={connectionId}
readOnly={readOnly}
/>

View File

@@ -0,0 +1,44 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { RiskMeasuresPageQuery } from "#/__generated__/core/RiskMeasuresPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import RiskMeasuresPage, { riskMeasuresPageQuery } from "./RiskMeasuresPage";
export default function RiskMeasuresPageLoader() {
const { riskId } = useParams<{ riskId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<RiskMeasuresPageQuery>(riskMeasuresPageQuery);
useEffect(() => {
if (riskId) {
loadQuery({ riskId });
}
}, [loadQuery, riskId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<RiskMeasuresPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -12,23 +12,32 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
import type { RiskObligationsTabFragment$key } from "#/__generated__/core/RiskObligationsTabFragment.graphql";
import type { RiskObligationsPageQuery } from "#/__generated__/core/RiskObligationsPageQuery.graphql";
import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const obligationsFragment = graphql`
fragment RiskObligationsTabFragment on Risk {
id
obligations(first: 100) @connection(key: "Risk__obligations") {
__id
edges {
node {
id
...LinkedObligationsCardFragment
export const riskObligationsPageQuery = graphql`
query RiskObligationsPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
id
canCreateObligationMapping: permission(
action: "core:risk:create-obligation-mapping"
)
canDeleteObligationMapping: permission(
action: "core:risk:delete-obligation-mapping"
)
obligations(first: 100) @connection(key: "RiskObligationsPage_obligations") {
__id
edges {
node {
id
...LinkedObligationsCardFragment
}
}
}
}
}
@@ -36,7 +45,7 @@ export const obligationsFragment = graphql`
`;
const attachObligationMutation = graphql`
mutation RiskObligationsTabCreateMutation(
mutation RiskObligationsPageCreateMutation(
$input: CreateRiskObligationMappingInput!
$connections: [ID!]!
) {
@@ -51,8 +60,8 @@ const attachObligationMutation = graphql`
}
`;
export const detachObligationMutation = graphql`
mutation RiskObligationsTabDetachMutation(
const detachObligationMutation = graphql`
mutation RiskObligationsPageDetachMutation(
$input: DeleteRiskObligationMappingInput!
$connections: [ID!]!
) {
@@ -62,23 +71,24 @@ export const detachObligationMutation = graphql`
}
`;
export default function RiskObligationsTab() {
const { risk } = useOutletContext<{
risk: RiskDetailLayoutQuery$data["node"];
}>();
const data = useFragment<RiskObligationsTabFragment$key>(
obligationsFragment,
risk,
);
const connectionId = data.obligations.__id;
const obligations = data.obligations?.edges?.map(edge => edge.node) ?? [];
interface RiskObligationsPageProps {
queryRef: PreloadedQuery<RiskObligationsPageQuery>;
}
const canLinkObligation = risk.canCreateObligationMapping;
const canUnlinkObligation = risk.canDeleteObligationMapping;
const readOnly = !canLinkObligation && !canUnlinkObligation;
export default function RiskObligationsPage(props: RiskObligationsPageProps) {
const data = usePreloadedQuery(riskObligationsPageQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const risk = data.node;
const connectionId = risk.obligations.__id;
const obligations = risk.obligations.edges.map(edge => edge.node);
const readOnly
= !risk.canCreateObligationMapping && !risk.canDeleteObligationMapping;
const incrementOptions = {
id: data.id,
id: risk.id,
node: "obligations(first:0)",
};
const [detachObligation, isDetaching] = useMutationWithIncrement(
@@ -103,7 +113,7 @@ export default function RiskObligationsTab() {
obligations={obligations}
onAttach={attachObligation}
onDetach={detachObligation}
params={{ riskId: data.id }}
params={{ riskId: risk.id }}
connectionId={connectionId}
variant="table"
readOnly={readOnly}

View File

@@ -0,0 +1,46 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { RiskObligationsPageQuery } from "#/__generated__/core/RiskObligationsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import RiskObligationsPage, {
riskObligationsPageQuery,
} from "./RiskObligationsPage";
export default function RiskObligationsPageLoader() {
const { riskId } = useParams<{ riskId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<RiskObligationsPageQuery>(riskObligationsPageQuery);
useEffect(() => {
if (riskId) {
loadQuery({ riskId });
}
}, [loadQuery, riskId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<RiskObligationsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -13,31 +13,42 @@
// PERFORMANCE OF THIS SOFTWARE.
import { RiskOverview } from "@probo/ui";
import { useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { RiskOverviewTabFragment$key } from "#/__generated__/core/RiskOverviewTabFragment.graphql";
import type { RiskOverviewPageQuery } from "#/__generated__/core/RiskOverviewPageQuery.graphql";
const overviewFragment = graphql`
fragment RiskOverviewTabFragment on Risk {
# eslint-disable-next-line relay/unused-fields
inherentLikelihood
# eslint-disable-next-line relay/unused-fields
inherentImpact
# eslint-disable-next-line relay/unused-fields
residualLikelihood
# eslint-disable-next-line relay/unused-fields
residualImpact
export const riskOverviewPageQuery = graphql`
query RiskOverviewPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
inherentLikelihood
inherentImpact
residualLikelihood
residualImpact
}
}
}
`;
export default function RiskOverviewTab() {
const { risk: key } = useOutletContext<{
risk: RiskOverviewTabFragment$key;
}>();
interface RiskOverviewPageProps {
queryRef: PreloadedQuery<RiskOverviewPageQuery>;
}
const risk = useFragment(overviewFragment, key);
export default function RiskOverviewPage(props: RiskOverviewPageProps) {
const data = usePreloadedQuery(riskOverviewPageQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const { inherentLikelihood, inherentImpact, residualLikelihood, residualImpact }
= data.node;
const risk = {
inherentLikelihood,
inherentImpact,
residualLikelihood,
residualImpact,
};
return (
<div className="grid grid-cols-2 gap-4">
<RiskOverview type="inherent" risk={risk} />

View File

@@ -0,0 +1,44 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { RiskOverviewPageQuery } from "#/__generated__/core/RiskOverviewPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import RiskOverviewPage, { riskOverviewPageQuery } from "./RiskOverviewPage";
export default function RiskOverviewPageLoader() {
const { riskId } = useParams<{ riskId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<RiskOverviewPageQuery>(riskOverviewPageQuery);
useEffect(() => {
if (riskId) {
loadQuery({ riskId });
}
}, [loadQuery, riskId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<RiskOverviewPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -43,44 +43,32 @@ export const riskRoutes = [
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("./tabs/RiskOverviewTab"),
),
Component: lazy(() => import("./overview/RiskOverviewPageLoader")),
},
{
path: "measures",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("./tabs/RiskMeasuresTab"),
),
Component: lazy(() => import("./measures/RiskMeasuresPageLoader")),
},
{
path: "documents",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("./tabs/RiskDocumentsTab"),
),
Component: lazy(() => import("./documents/RiskDocumentsPageLoader")),
},
{
path: "controls",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("./tabs/RiskControlsTab"),
),
Component: lazy(() => import("./controls/RiskControlsPageLoader")),
},
{
path: "obligations",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("./tabs/RiskObligationsTab"),
),
Component: lazy(() => import("./obligations/RiskObligationsPageLoader")),
},
{
path: "scenarios",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("./scenarios/RiskScenariosPage"),
),
Component: lazy(() => import("./scenarios/RiskScenariosPageLoader")),
},
],
},

View File

@@ -25,30 +25,33 @@ import {
Tr,
TrButton,
} from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
import type { RiskScenariosPageFragment$key } from "#/__generated__/core/RiskScenariosPageFragment.graphql";
import type { RiskScenariosPageLinkMutation } from "#/__generated__/core/RiskScenariosPageLinkMutation.graphql";
import type { RiskScenariosPageQuery } from "#/__generated__/core/RiskScenariosPageQuery.graphql";
import type { RiskScenariosPageUnlinkMutation } from "#/__generated__/core/RiskScenariosPageUnlinkMutation.graphql";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { LinkScenarioDialog } from "../_components/LinkScenarioDialog";
const fragment = graphql`
fragment RiskScenariosPageFragment on Risk {
id
scenarios(first: 100)
@connection(key: "RiskScenariosPage_scenarios", filters: []) {
__id
edges {
node {
id
name
description
scope { riskAssessmentId }
export const riskScenariosPageQuery = graphql`
query RiskScenariosPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
id
scenarios(first: 100)
@connection(key: "RiskScenariosPage_scenarios", filters: []) {
__id
edges {
node {
id
name
description
scope { riskAssessmentId }
}
}
}
}
}
@@ -84,16 +87,21 @@ const unlinkMutation = graphql`
}
`;
export default function RiskScenariosPage() {
interface RiskScenariosPageProps {
queryRef: PreloadedQuery<RiskScenariosPageQuery>;
}
export default function RiskScenariosPage(props: RiskScenariosPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { risk } = useOutletContext<{
risk: RiskDetailLayoutQuery$data["node"];
}>();
const data = useFragment<RiskScenariosPageFragment$key>(fragment, risk);
const scenarios = data.scenarios.edges.map(e => e.node);
const connectionId = data.scenarios.__id;
const riskId = data.id;
const data = usePreloadedQuery(riskScenariosPageQuery, props.queryRef);
if (data.node?.__typename !== "Risk") {
throw new Error("Risk not found");
}
const risk = data.node;
const scenarios = risk.scenarios.edges.map(e => e.node);
const connectionId = risk.scenarios.__id;
const riskId = risk.id;
const incrementOptions = {
id: riskId,

View File

@@ -0,0 +1,44 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { RiskScenariosPageQuery } from "#/__generated__/core/RiskScenariosPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import RiskScenariosPage, { riskScenariosPageQuery } from "./RiskScenariosPage";
export default function RiskScenariosPageLoader() {
const { riskId } = useParams<{ riskId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<RiskScenariosPageQuery>(riskScenariosPageQuery);
useEffect(() => {
if (riskId) {
loadQuery({ riskId });
}
}, [loadQuery, riskId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<RiskScenariosPage queryRef={queryRef} />
</Suspense>
);
}