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

View File

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

View File

@@ -12,50 +12,37 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { getTreatment, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown,
Button, Button,
DropdownItem,
IconPageTextLine, IconPageTextLine,
IconPencil,
IconPlusLarge, IconPlusLarge,
IconTrashCan,
IconUpload, IconUpload,
PageHeader, PageHeader,
RisksChart, RisksChart,
SeverityBadge,
Tbody, Tbody,
Td,
Th, Th,
Thead, Thead,
Tr, Tr,
useConfirm,
useDialogRef,
} from "@probo/ui"; } from "@probo/ui";
import { import {
graphql, graphql,
type PreloadedQuery, type PreloadedQuery,
useMutation,
usePaginationFragment, usePaginationFragment,
usePreloadedQuery, usePreloadedQuery,
} from "react-relay"; } from "react-relay";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { RisksPageDeleteMutation } from "#/__generated__/core/RisksPageDeleteMutation.graphql"; import type { RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql";
import type { RisksPageFragment$data, RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql";
import type { RisksPageQuery } from "#/__generated__/core/RisksPageQuery.graphql"; import type { RisksPageQuery } from "#/__generated__/core/RisksPageQuery.graphql";
import type { RisksPageRefetchQuery } from "#/__generated__/core/RisksPageRefetchQuery.graphql"; import type { RisksPageRefetchQuery } from "#/__generated__/core/RisksPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable"; import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog"; import { FormRiskDialog } from "./_components/FormRiskDialog";
import FormRiskDialog from "./FormRiskDialog"; import { PublishRiskListDialog } from "./_components/PublishRiskListDialog";
import { RiskRow } from "./_components/RiskRow";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const risksPageQuery = graphql` export const risksPageQuery = graphql`
query RisksPageQuery($organizationId: ID!) { query RisksPageQuery($organizationId: ID!) {
@@ -83,8 +70,6 @@ const risksFragment = graphql`
canPublishRisk: permission(action: "core:risk:publish") canPublishRisk: permission(action: "core:risk:publish")
risksDocument { risksDocument {
id id
currentPublishedMajor
currentPublishedMinor
defaultApprovers { defaultApprovers {
id id
} }
@@ -101,45 +86,26 @@ const risksFragment = graphql`
node { node {
id id
name name
category
treatment
owner {
id
fullName
}
inherentLikelihood inherentLikelihood
inherentImpact inherentImpact
residualLikelihood residualLikelihood
residualImpact residualImpact
inherentRiskScore
residualRiskScore
canUpdate: permission(action: "core:risk:update") canUpdate: permission(action: "core:risk:update")
canDelete: permission(action: "core:risk:delete") 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"; export const RisksConnectionKey = "RisksPage_risks";
type Props = { interface RisksPageProps {
queryRef: PreloadedQuery<RisksPageQuery>; queryRef: PreloadedQuery<RisksPageQuery>;
}; }
export default function RisksPage(props: Props) { export default function RisksPage(props: RisksPageProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -156,6 +122,22 @@ export default function RisksPage(props: Props) {
const risks = fragmentData.risks?.edges.map(edge => edge.node) ?? []; const risks = fragmentData.risks?.edges.map(edge => edge.node) ?? [];
const connectionId = fragmentData.risks.__id; const connectionId = fragmentData.risks.__id;
const chartRisks = risks.map(({
id,
name,
inherentLikelihood,
inherentImpact,
residualLikelihood,
residualImpact,
}) => ({
id,
name,
inherentLikelihood,
inherentImpact,
residualLikelihood,
residualImpact,
}));
const refetch = ({ const refetch = ({
order, order,
}: { }: {
@@ -181,8 +163,9 @@ export default function RisksPage(props: Props) {
usePageTitle(__("Risks")); usePageTitle(__("Risks"));
const hasAnyAction const hasAnyAction = risks.some(
= risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete); ({ canDelete, canUpdate }) => canUpdate || canDelete,
);
const defaultApproverIds const defaultApproverIds
= risksDocument?.defaultApprovers?.map(a => a.id) ?? []; = risksDocument?.defaultApprovers?.map(a => a.id) ?? [];
@@ -236,12 +219,12 @@ export default function RisksPage(props: Props) {
<RisksChart <RisksChart
organizationId={organizationId} organizationId={organizationId}
type="inherent" type="inherent"
risks={risks} risks={chartRisks}
/> />
<RisksChart <RisksChart
organizationId={organizationId} organizationId={organizationId}
type="residual" type="residual"
risks={risks} risks={chartRisks}
/> />
</div> </div>
<SortableTable {...pagination} refetch={refetch}> <SortableTable {...pagination} refetch={refetch}>
@@ -261,12 +244,11 @@ export default function RisksPage(props: Props) {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{risks?.map(risk => ( {risks.map(risk => (
<RiskRow <RiskRow
risk={risk}
key={risk.id} key={risk.id}
riskKey={risk}
connectionId={connectionId} connectionId={connectionId}
organizationId={organizationId}
hasAnyAction={hasAnyAction} hasAnyAction={hasAnyAction}
/> />
))} ))}
@@ -275,88 +257,3 @@ export default function RisksPage(props: Props) {
</div> </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 // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // 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 { useToggle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
@@ -30,11 +30,14 @@ import {
Select, Select,
Textarea, Textarea,
useDialogRef, useDialogRef,
useToast,
} from "@probo/ui"; } from "@probo/ui";
import { type ReactNode, useMemo } from "react"; import { type ReactNode, useMemo } from "react";
import type { FieldErrors } from "react-hook-form"; import type { FieldErrors } from "react-hook-form";
import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; 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 { FormRiskDialogMutation } from "#/__generated__/core/FormRiskDialogMutation.graphql";
import type { FormRiskDialogUpdateRiskMutation } from "#/__generated__/core/FormRiskDialogUpdateRiskMutation.graphql"; import type { FormRiskDialogUpdateRiskMutation } from "#/__generated__/core/FormRiskDialogUpdateRiskMutation.graphql";
import { import {
@@ -45,20 +48,18 @@ import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { import {
type RiskData, type RiskData,
type RiskForm, type RiskForm,
type RiskKey,
useRiskForm, useRiskForm,
} from "#/hooks/forms/useRiskForm"; } from "#/hooks/forms/useRiskForm";
import { useFetchQuery } from "#/hooks/useFetchQuery"; import { useFetchQuery } from "#/hooks/useFetchQuery";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = { interface FormRiskDialogProps {
trigger?: ReactNode; trigger?: ReactNode;
risk?: RiskKey; risk?: FormRiskDialog_risk$key;
connection?: string; connection?: string;
ref?: ReturnType<typeof useDialogRef>; ref?: ReturnType<typeof useDialogRef>;
onSuccess?: () => void; onSuccess?: () => void;
}; }
type RiskTemplate = { type RiskTemplate = {
category: string; category: string;
@@ -66,6 +67,26 @@ type RiskTemplate = {
description: string; 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` const createRiskMutation = graphql`
mutation FormRiskDialogMutation( mutation FormRiskDialogMutation(
$input: CreateRiskInput! $input: CreateRiskInput!
@@ -74,7 +95,7 @@ const createRiskMutation = graphql`
createRisk(input: $input) { createRisk(input: $input) {
riskEdge @prependEdge(connections: $connections) { riskEdge @prependEdge(connections: $connections) {
node { node {
...useRiskFormFragment ...FormRiskDialog_risk
} }
} }
} }
@@ -85,44 +106,60 @@ const updateRiskMutation = graphql`
mutation FormRiskDialogUpdateRiskMutation($input: UpdateRiskInput!) { mutation FormRiskDialogUpdateRiskMutation($input: UpdateRiskInput!) {
updateRisk(input: $input) { updateRisk(input: $input) {
risk { risk {
...useRiskFormFragment ...FormRiskDialog_risk
} }
} }
} }
`; `;
/** export function FormRiskDialog({
* Dialog to create or update a risk
*/
export default function FormRiskDialog({
trigger, trigger,
risk, risk: riskKey,
connection, connection,
ref: refProps, ref: refProps,
onSuccess, onSuccess,
}: Props) { }: FormRiskDialogProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const dialogRef = useDialogRef(); const dialogRef = useDialogRef();
const ref = refProps ?? dialogRef; 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 } const { control, handleSubmit, setValue, register, watch, formState, reset }
= useRiskForm(risk); = useRiskForm(formDefaults);
const errors = formState.errors ?? {}; const errors = formState.errors ?? {};
const [createRisk, isLoadingCreate] const [createRisk, isCreating]
= useMutationWithToasts<FormRiskDialogMutation>(createRiskMutation); = useMutation<FormRiskDialogMutation>(createRiskMutation);
const [updateRisk, isLoadingUpdate] const [updateRisk, isUpdating]
= useMutationWithToasts<FormRiskDialogUpdateRiskMutation>(updateRiskMutation); = useMutation<FormRiskDialogUpdateRiskMutation>(updateRiskMutation);
const isLoading = isLoadingCreate || isLoadingUpdate; const isLoading = isCreating || isUpdating;
const onTemplateChange = (risk: RiskTemplate) => { const onTemplateChange = (template: RiskTemplate) => {
setValue("name", risk.name); setValue("name", template.name);
setValue("description", risk.description); setValue("description", template.description);
}; };
const onSubmit = async (data: RiskData) => { const onSubmit = (data: RiskData) => {
if (risk) { if (risk) {
await updateRisk({ updateRisk({
variables: { variables: {
input: { input: {
id: risk.id, id: risk.id,
@@ -130,15 +167,28 @@ export default function FormRiskDialog({
description: data.description || null, description: data.description || null,
}, },
}, },
successMessage: __("Risk updated successfully."), onCompleted() {
errorMessage: __("Failed to update risk"), toast({
onSuccess: () => { title: __("Success"),
description: __("Risk updated successfully."),
variant: "success",
});
ref?.current?.close(); ref?.current?.close();
}, },
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update risk"),
error as GraphQLError,
),
variant: "error",
});
},
}); });
return; return;
} }
await createRisk({ createRisk({
variables: { variables: {
input: { input: {
...data, ...data,
@@ -147,13 +197,26 @@ export default function FormRiskDialog({
}, },
connections: [connection!], connections: [connection!],
}, },
successMessage: __("Risk created successfully."), onCompleted() {
errorMessage: __("Failed to create risk"), toast({
onSuccess: () => { title: __("Success"),
description: __("Risk created successfully."),
variant: "success",
});
ref?.current?.close(); ref?.current?.close();
reset(); reset();
onSuccess?.(); 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)}> <form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent className="grid grid-cols-[1fr_420px]"> <DialogContent className="grid grid-cols-[1fr_420px]">
{/* Main form */}
<div className="py-8 px-12 space-y-6"> <div className="py-8 px-12 space-y-6">
<TemplateSelector <TemplateSelector
onChange={onTemplateChange} onChange={onTemplateChange}
@@ -209,7 +271,6 @@ export default function FormRiskDialog({
</div> </div>
</div> </div>
{/* Properties form */}
<div className="py-5 px-6 bg-subtle"> <div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label> <Label>{__("Properties")}</Label>

View File

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

View File

@@ -48,19 +48,19 @@ const publishMutation = graphql`
} }
`; `;
type Props = { interface PublishRiskListDialogProps {
children: ReactNode; children: ReactNode;
organizationId: string; organizationId: string;
defaultApproverIds?: string[]; defaultApproverIds?: string[];
onPublished?: (documentId: string) => void; onPublished?: (documentId: string) => void;
}; }
export function PublishRiskListDialog({ export function PublishRiskListDialog({
children, children,
organizationId, organizationId,
defaultApproverIds, defaultApproverIds,
onPublished, onPublished,
}: Props) { }: PublishRiskListDialogProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const dialogRef = useDialogRef(); 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 { useTranslate } from "@probo/i18n";
import { Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui"; import { Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { graphql, usePaginationFragment } from "react-relay"; import {
import { useOutletContext } from "react-router"; graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import type { RiskControlsTabControlsQuery } from "#/__generated__/core/RiskControlsTabControlsQuery.graphql"; import type { RiskControlsPage_risk$key } from "#/__generated__/core/RiskControlsPage_risk.graphql";
import type { RiskControlsTabFragment$key } from "#/__generated__/core/RiskControlsTabFragment.graphql"; import type { RiskControlsPageQuery } from "#/__generated__/core/RiskControlsPageQuery.graphql";
import type { RiskControlsPageRefetchQuery } from "#/__generated__/core/RiskControlsPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable"; import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
export const controlsFragment = graphql` export const riskControlsPageQuery = graphql`
fragment RiskControlsTabFragment on Risk query RiskControlsPageQuery($riskId: ID!) {
node(id: $riskId) {
__typename
... on Risk {
...RiskControlsPage_risk
}
}
}
`;
const controlsFragment = graphql`
fragment RiskControlsPage_risk on Risk
@argumentDefinitions( @argumentDefinitions(
first: { type: "Int", defaultValue: 20 } first: { type: "Int", defaultValue: 20 }
after: { type: "CursorKey" } after: { type: "CursorKey" }
@@ -33,7 +49,7 @@ export const controlsFragment = graphql`
order: { type: "ControlOrder", defaultValue: null } order: { type: "ControlOrder", defaultValue: null }
filter: { type: "ControlFilter", defaultValue: null } filter: { type: "ControlFilter", defaultValue: null }
) )
@refetchable(queryName: "RiskControlsTabControlsQuery") { @refetchable(queryName: "RiskControlsPageRefetchQuery") {
id id
controls( controls(
first: $first first: $first
@@ -42,7 +58,7 @@ export const controlsFragment = graphql`
before: $before before: $before
orderBy: $order orderBy: $order
filter: $filter filter: $filter
) @connection(key: "RiskControlsTab_controls") { ) @connection(key: "RiskControlsPage_controls") {
edges { edges {
node { node {
id id
@@ -57,17 +73,23 @@ export const controlsFragment = graphql`
} }
} }
`; `;
export default function RiskControlsTab() {
const { risk } = useOutletContext<{ interface RiskControlsPageProps {
risk: RiskControlsTabFragment$key & { id: string }; queryRef: PreloadedQuery<RiskControlsPageQuery>;
}>(); }
export default function RiskControlsPage(props: RiskControlsPageProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const pagination = usePaginationFragment<
RiskControlsTabControlsQuery,
RiskControlsTabFragment$key
>(controlsFragment, risk);
const controls = pagination.data.controls.edges.map(edge => edge.node);
const organizationId = useOrganizationId(); 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 ( return (
<SortableTable <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 // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay"; import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useOutletContext } from "react-router";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import type { RiskDocumentsPageQuery } from "#/__generated__/core/RiskDocumentsPageQuery.graphql";
import type { RiskDocumentsTabFragment$key } from "#/__generated__/core/RiskDocumentsTabFragment.graphql";
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard"; import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const documentsFragment = graphql` export const riskDocumentsPageQuery = graphql`
fragment RiskDocumentsTabFragment on Risk { query RiskDocumentsPageQuery($riskId: ID!) {
id node(id: $riskId) {
documents(first: 100) @connection(key: "Risk__documents") { __typename
__id ... on Risk {
edges { id
node { canCreateDocumentMapping: permission(
id action: "core:risk:create-document-mapping"
...LinkedDocumentsCardFragment )
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` const attachDocumentMutation = graphql`
mutation RiskDocumentsTabCreateMutation( mutation RiskDocumentsPageCreateMutation(
$input: CreateRiskDocumentMappingInput! $input: CreateRiskDocumentMappingInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -51,8 +60,8 @@ const attachDocumentMutation = graphql`
} }
`; `;
export const detachDocumentMutation = graphql` const detachDocumentMutation = graphql`
mutation RiskDocumentsTabDetachMutation( mutation RiskDocumentsPageDetachMutation(
$input: DeleteRiskDocumentMappingInput! $input: DeleteRiskDocumentMappingInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -62,23 +71,23 @@ export const detachDocumentMutation = graphql`
} }
`; `;
export default function RiskDocumentsTab() { interface RiskDocumentsPageProps {
const { risk } = useOutletContext<{ queryRef: PreloadedQuery<RiskDocumentsPageQuery>;
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) ?? [];
const canLinkDocument = risk.canCreateDocumentMapping; export default function RiskDocumentsPage(props: RiskDocumentsPageProps) {
const canUnlinkDocument = risk.canDeleteDocumentMapping; const data = usePreloadedQuery(riskDocumentsPageQuery, props.queryRef);
const readOnly = !canLinkDocument && !canUnlinkDocument; 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 = { const incrementOptions = {
id: data.id, id: risk.id,
node: "documents(first:0)", node: "documents(first:0)",
}; };
const [detachDocument, isDetaching] = useMutationWithIncrement( const [detachDocument, isDetaching] = useMutationWithIncrement(
@@ -103,7 +112,7 @@ export default function RiskDocumentsTab() {
documents={documents} documents={documents}
onAttach={attachDocument} onAttach={attachDocument}
onDetach={detachDocument} onDetach={detachDocument}
params={{ riskId: data.id }} params={{ riskId: risk.id }}
connectionId={connectionId} connectionId={connectionId}
readOnly={readOnly} 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 // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay"; import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useOutletContext } from "react-router";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import type { RiskMeasuresPageQuery } from "#/__generated__/core/RiskMeasuresPageQuery.graphql";
import type { RiskMeasuresTabFragment$key } from "#/__generated__/core/RiskMeasuresTabFragment.graphql";
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard"; import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const measuresFragment = graphql` export const riskMeasuresPageQuery = graphql`
fragment RiskMeasuresTabFragment on Risk { query RiskMeasuresPageQuery($riskId: ID!) {
id node(id: $riskId) {
measures(first: 100) @connection(key: "Risk__measures") { __typename
__id ... on Risk {
edges { id
node { canCreateMeasureMapping: permission(
id action: "core:risk:create-measure-mapping"
...LinkedMeasuresCardFragment )
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` const attachMeasureMutation = graphql`
mutation RiskMeasuresTabCreateMutation( mutation RiskMeasuresPageCreateMutation(
$input: CreateRiskMeasureMappingInput! $input: CreateRiskMeasureMappingInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -51,8 +60,8 @@ const attachMeasureMutation = graphql`
} }
`; `;
export const detachMeasureMutation = graphql` const detachMeasureMutation = graphql`
mutation RiskMeasuresTabDetachMutation( mutation RiskMeasuresPageDetachMutation(
$input: DeleteRiskMeasureMappingInput! $input: DeleteRiskMeasureMappingInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -62,20 +71,23 @@ export const detachMeasureMutation = graphql`
} }
`; `;
export default function RiskMeasuresTab() { interface RiskMeasuresPageProps {
const { risk } = useOutletContext<{ queryRef: PreloadedQuery<RiskMeasuresPageQuery>;
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) ?? [];
const canLinkMeasure = risk.canCreateMeasureMapping; export default function RiskMeasuresPage(props: RiskMeasuresPageProps) {
const canUnlinkMeasure = risk.canDeleteMeasureMapping; const data = usePreloadedQuery(riskMeasuresPageQuery, props.queryRef);
const readOnly = !canLinkMeasure && !canUnlinkMeasure; 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 = { const incrementOptions = {
id: data.id, id: risk.id,
node: "measures(first:0)", node: "measures(first:0)",
}; };
const [detachMeasure, isDetaching] = useMutationWithIncrement( const [detachMeasure, isDetaching] = useMutationWithIncrement(
@@ -100,7 +112,7 @@ export default function RiskMeasuresTab() {
measures={measures} measures={measures}
onAttach={attachMeasure} onAttach={attachMeasure}
onDetach={detachMeasure} onDetach={detachMeasure}
params={{ riskId: data.id }} params={{ riskId: risk.id }}
connectionId={connectionId} connectionId={connectionId}
readOnly={readOnly} 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 // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay"; import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useOutletContext } from "react-router";
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import type { RiskObligationsPageQuery } from "#/__generated__/core/RiskObligationsPageQuery.graphql";
import type { RiskObligationsTabFragment$key } from "#/__generated__/core/RiskObligationsTabFragment.graphql";
import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard"; import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const obligationsFragment = graphql` export const riskObligationsPageQuery = graphql`
fragment RiskObligationsTabFragment on Risk { query RiskObligationsPageQuery($riskId: ID!) {
id node(id: $riskId) {
obligations(first: 100) @connection(key: "Risk__obligations") { __typename
__id ... on Risk {
edges { id
node { canCreateObligationMapping: permission(
id action: "core:risk:create-obligation-mapping"
...LinkedObligationsCardFragment )
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` const attachObligationMutation = graphql`
mutation RiskObligationsTabCreateMutation( mutation RiskObligationsPageCreateMutation(
$input: CreateRiskObligationMappingInput! $input: CreateRiskObligationMappingInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -51,8 +60,8 @@ const attachObligationMutation = graphql`
} }
`; `;
export const detachObligationMutation = graphql` const detachObligationMutation = graphql`
mutation RiskObligationsTabDetachMutation( mutation RiskObligationsPageDetachMutation(
$input: DeleteRiskObligationMappingInput! $input: DeleteRiskObligationMappingInput!
$connections: [ID!]! $connections: [ID!]!
) { ) {
@@ -62,23 +71,24 @@ export const detachObligationMutation = graphql`
} }
`; `;
export default function RiskObligationsTab() { interface RiskObligationsPageProps {
const { risk } = useOutletContext<{ queryRef: PreloadedQuery<RiskObligationsPageQuery>;
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) ?? [];
const canLinkObligation = risk.canCreateObligationMapping; export default function RiskObligationsPage(props: RiskObligationsPageProps) {
const canUnlinkObligation = risk.canDeleteObligationMapping; const data = usePreloadedQuery(riskObligationsPageQuery, props.queryRef);
const readOnly = !canLinkObligation && !canUnlinkObligation; 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 = { const incrementOptions = {
id: data.id, id: risk.id,
node: "obligations(first:0)", node: "obligations(first:0)",
}; };
const [detachObligation, isDetaching] = useMutationWithIncrement( const [detachObligation, isDetaching] = useMutationWithIncrement(
@@ -103,7 +113,7 @@ export default function RiskObligationsTab() {
obligations={obligations} obligations={obligations}
onAttach={attachObligation} onAttach={attachObligation}
onDetach={detachObligation} onDetach={detachObligation}
params={{ riskId: data.id }} params={{ riskId: risk.id }}
connectionId={connectionId} connectionId={connectionId}
variant="table" variant="table"
readOnly={readOnly} 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. // PERFORMANCE OF THIS SOFTWARE.
import { RiskOverview } from "@probo/ui"; import { RiskOverview } from "@probo/ui";
import { useFragment } from "react-relay"; import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime"; 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` export const riskOverviewPageQuery = graphql`
fragment RiskOverviewTabFragment on Risk { query RiskOverviewPageQuery($riskId: ID!) {
# eslint-disable-next-line relay/unused-fields node(id: $riskId) {
inherentLikelihood __typename
# eslint-disable-next-line relay/unused-fields ... on Risk {
inherentImpact inherentLikelihood
# eslint-disable-next-line relay/unused-fields inherentImpact
residualLikelihood residualLikelihood
# eslint-disable-next-line relay/unused-fields residualImpact
residualImpact }
}
} }
`; `;
export default function RiskOverviewTab() { interface RiskOverviewPageProps {
const { risk: key } = useOutletContext<{ queryRef: PreloadedQuery<RiskOverviewPageQuery>;
risk: RiskOverviewTabFragment$key; }
}>();
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 ( return (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<RiskOverview type="inherent" risk={risk} /> <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", path: "overview",
Fallback: LinkCardSkeleton, Fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(() => import("./overview/RiskOverviewPageLoader")),
() => import("./tabs/RiskOverviewTab"),
),
}, },
{ {
path: "measures", path: "measures",
Fallback: LinkCardSkeleton, Fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(() => import("./measures/RiskMeasuresPageLoader")),
() => import("./tabs/RiskMeasuresTab"),
),
}, },
{ {
path: "documents", path: "documents",
Fallback: LinkCardSkeleton, Fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(() => import("./documents/RiskDocumentsPageLoader")),
() => import("./tabs/RiskDocumentsTab"),
),
}, },
{ {
path: "controls", path: "controls",
Fallback: LinkCardSkeleton, Fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(() => import("./controls/RiskControlsPageLoader")),
() => import("./tabs/RiskControlsTab"),
),
}, },
{ {
path: "obligations", path: "obligations",
Fallback: LinkCardSkeleton, Fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(() => import("./obligations/RiskObligationsPageLoader")),
() => import("./tabs/RiskObligationsTab"),
),
}, },
{ {
path: "scenarios", path: "scenarios",
Fallback: LinkCardSkeleton, Fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(() => import("./scenarios/RiskScenariosPageLoader")),
() => import("./scenarios/RiskScenariosPage"),
),
}, },
], ],
}, },

View File

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