diff --git a/apps/console/src/hooks/forms/useRiskForm.tsx b/apps/console/src/hooks/forms/useRiskForm.tsx index 144f4de99..b231e1a2b 100644 --- a/apps/console/src/hooks/forms/useRiskForm.tsx +++ b/apps/console/src/hooks/forms/useRiskForm.tsx @@ -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 ? { diff --git a/apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx b/apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx index d979922a1..7482352c8 100644 --- a/apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx +++ b/apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx @@ -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; -}; +} -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(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 (
- {/* Header */}
)} - risk={{ id: riskId, ...risk }} + risk={risk} /> )} {risk.canDelete && ( @@ -251,7 +224,7 @@ export default function RiskDetailLayout(props: Props) { - + diff --git a/apps/console/src/pages/organizations/risks/RisksPage.tsx b/apps/console/src/pages/organizations/risks/RisksPage.tsx index e89a100fa..ff6b06c6d 100644 --- a/apps/console/src/pages/organizations/risks/RisksPage.tsx +++ b/apps/console/src/pages/organizations/risks/RisksPage.tsx @@ -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; -}; +} -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) {
@@ -261,12 +244,11 @@ export default function RisksPage(props: Props) { - {risks?.map(risk => ( + {risks.map(risk => ( ))} @@ -275,88 +257,3 @@ export default function RisksPage(props: Props) {
); } - -type RowProps = { - risk: NodeOf; - connectionId: string; - organizationId: string; - hasAnyAction: boolean; -}; - -function RiskRow(props: RowProps) { - const { __ } = useTranslate(); - const { risk, connectionId, organizationId } = props; - const [deleteRisk] = useMutation(deleteRiskMutation); - const confirm = useConfirm(); - const onDelete = () => { - confirm( - () => - new Promise((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 ( - <> - - - {risk.name} - {risk.category} - {getTreatment(__, risk.treatment)} - - - - - - - {risk.owner?.fullName || __("Unassigned")} - {props.hasAnyAction && ( - - - {risk.canUpdate && ( - formDialogRef.current?.open()} - > - {__("Edit")} - - )} - - {risk.canDelete && ( - - {__("Delete")} - - )} - - - )} - - - ); -} diff --git a/apps/console/src/pages/organizations/risks/FormRiskDialog.tsx b/apps/console/src/pages/organizations/risks/_components/FormRiskDialog.tsx similarity index 78% rename from apps/console/src/pages/organizations/risks/FormRiskDialog.tsx rename to apps/console/src/pages/organizations/risks/_components/FormRiskDialog.tsx index 2e57d2e08..f53d1645f 100644 --- a/apps/console/src/pages/organizations/risks/FormRiskDialog.tsx +++ b/apps/console/src/pages/organizations/risks/_components/FormRiskDialog.tsx @@ -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; 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(createRiskMutation); - const [updateRisk, isLoadingUpdate] - = useMutationWithToasts(updateRiskMutation); - const isLoading = isLoadingCreate || isLoadingUpdate; + const [createRisk, isCreating] + = useMutation(createRiskMutation); + const [updateRisk, isUpdating] + = useMutation(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({ >
void handleSubmit(onSubmit)(e)}> - {/* Main form */}
- {/* Properties form */}
diff --git a/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx b/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx index ea69d7679..7388f21bf 100644 --- a/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx +++ b/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx @@ -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(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 ( - + - }> - - + {queryRef + ? ( + }> + + + ) + : ( + + )} ); } -function LinkScenarioDialogContent(props: Omit) { - const organizationId = useOrganizationId(); - const query = useLazyLoadQuery( - scenariosQuery, - { - organizationId, - }, - { fetchPolicy: "network-only" }, - ); +type ContentProps = Omit & { + queryRef: PreloadedQuery; +}; + +function LinkScenarioDialogContent(props: ContentProps) { + const query = usePreloadedQuery(scenariosQuery, props.queryRef); const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment( scenariosFragment, diff --git a/apps/console/src/pages/organizations/risks/dialogs/PublishRiskListDialog.tsx b/apps/console/src/pages/organizations/risks/_components/PublishRiskListDialog.tsx similarity index 98% rename from apps/console/src/pages/organizations/risks/dialogs/PublishRiskListDialog.tsx rename to apps/console/src/pages/organizations/risks/_components/PublishRiskListDialog.tsx index 0501da0fd..9bce13e89 100644 --- a/apps/console/src/pages/organizations/risks/dialogs/PublishRiskListDialog.tsx +++ b/apps/console/src/pages/organizations/risks/_components/PublishRiskListDialog.tsx @@ -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(); diff --git a/apps/console/src/pages/organizations/risks/_components/RiskRow.tsx b/apps/console/src/pages/organizations/risks/_components/RiskRow.tsx new file mode 100644 index 000000000..26634fe6c --- /dev/null +++ b/apps/console/src/pages/organizations/risks/_components/RiskRow.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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(deleteRiskMutation); + const confirm = useConfirm(); + const { toast } = useToast(); + const formDialogRef = useDialogRef(); + + const onDelete = () => { + confirm( + () => + new Promise((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 ( + <> + + + {risk.name} + {risk.category} + {getTreatment(__, risk.treatment)} + + + + + + + {risk.owner?.fullName || __("Unassigned")} + {props.hasAnyAction && ( + + + {risk.canUpdate && ( + formDialogRef.current?.open()} + > + {__("Edit")} + + )} + + {risk.canDelete && ( + + {__("Delete")} + + )} + + + )} + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/_components/ScenarioActions.tsx b/apps/console/src/pages/organizations/risks/_components/ScenarioActions.tsx deleted file mode 100644 index ebbfdc787..000000000 --- a/apps/console/src/pages/organizations/risks/_components/ScenarioActions.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// 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(unlinkMutation); - - const handleUnlink = () => { - confirm( - () => { - unlinkScenario({ - variables: { - input: { - riskAssessmentScenarioId: props.scenarioId, - riskId: props.riskId, - }, - connections: [props.connectionId], - }, - }); - }, - { - message: __("Remove this scenario from the risk?"), - }, - ); - }; - - return ( - - - {__("Remove")} - - - ); -} diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskControlsTab.tsx b/apps/console/src/pages/organizations/risks/controls/RiskControlsPage.tsx similarity index 70% rename from apps/console/src/pages/organizations/risks/tabs/RiskControlsTab.tsx rename to apps/console/src/pages/organizations/risks/controls/RiskControlsPage.tsx index cba0d8410..f6010e1fd 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskControlsTab.tsx +++ b/apps/console/src/pages/organizations/risks/controls/RiskControlsPage.tsx @@ -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; +} + +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 ( . +// +// 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); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx b/apps/console/src/pages/organizations/risks/documents/RiskDocumentsPage.tsx similarity index 58% rename from apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx rename to apps/console/src/pages/organizations/risks/documents/RiskDocumentsPage.tsx index 86cbde920..979c3a05e 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx +++ b/apps/console/src/pages/organizations/risks/documents/RiskDocumentsPage.tsx @@ -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( - documentsFragment, - risk, - ); - const connectionId = data.documents.__id; - const documents = data.documents?.edges?.map(edge => edge.node) ?? []; +interface RiskDocumentsPageProps { + queryRef: PreloadedQuery; +} - 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} /> diff --git a/apps/console/src/pages/organizations/risks/documents/RiskDocumentsPageLoader.tsx b/apps/console/src/pages/organizations/risks/documents/RiskDocumentsPageLoader.tsx new file mode 100644 index 000000000..5a59468a2 --- /dev/null +++ b/apps/console/src/pages/organizations/risks/documents/RiskDocumentsPageLoader.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx b/apps/console/src/pages/organizations/risks/measures/RiskMeasuresPage.tsx similarity index 59% rename from apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx rename to apps/console/src/pages/organizations/risks/measures/RiskMeasuresPage.tsx index 9d0320dfd..59d25e3f1 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx +++ b/apps/console/src/pages/organizations/risks/measures/RiskMeasuresPage.tsx @@ -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(measuresFragment, risk); - const connectionId = data.measures.__id; - const measures = data.measures?.edges?.map(edge => edge.node) ?? []; +interface RiskMeasuresPageProps { + queryRef: PreloadedQuery; +} - 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} /> diff --git a/apps/console/src/pages/organizations/risks/measures/RiskMeasuresPageLoader.tsx b/apps/console/src/pages/organizations/risks/measures/RiskMeasuresPageLoader.tsx new file mode 100644 index 000000000..89d0382ca --- /dev/null +++ b/apps/console/src/pages/organizations/risks/measures/RiskMeasuresPageLoader.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx b/apps/console/src/pages/organizations/risks/obligations/RiskObligationsPage.tsx similarity index 58% rename from apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx rename to apps/console/src/pages/organizations/risks/obligations/RiskObligationsPage.tsx index 6bcb832f5..6a61500da 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx +++ b/apps/console/src/pages/organizations/risks/obligations/RiskObligationsPage.tsx @@ -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( - obligationsFragment, - risk, - ); - const connectionId = data.obligations.__id; - const obligations = data.obligations?.edges?.map(edge => edge.node) ?? []; +interface RiskObligationsPageProps { + queryRef: PreloadedQuery; +} - 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} diff --git a/apps/console/src/pages/organizations/risks/obligations/RiskObligationsPageLoader.tsx b/apps/console/src/pages/organizations/risks/obligations/RiskObligationsPageLoader.tsx new file mode 100644 index 000000000..b96700b87 --- /dev/null +++ b/apps/console/src/pages/organizations/risks/obligations/RiskObligationsPageLoader.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskOverviewTab.tsx b/apps/console/src/pages/organizations/risks/overview/RiskOverviewPage.tsx similarity index 52% rename from apps/console/src/pages/organizations/risks/tabs/RiskOverviewTab.tsx rename to apps/console/src/pages/organizations/risks/overview/RiskOverviewPage.tsx index 36db41021..f8836a6a4 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskOverviewTab.tsx +++ b/apps/console/src/pages/organizations/risks/overview/RiskOverviewPage.tsx @@ -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; +} - 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 (
diff --git a/apps/console/src/pages/organizations/risks/overview/RiskOverviewPageLoader.tsx b/apps/console/src/pages/organizations/risks/overview/RiskOverviewPageLoader.tsx new file mode 100644 index 000000000..97e978094 --- /dev/null +++ b/apps/console/src/pages/organizations/risks/overview/RiskOverviewPageLoader.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/routes.ts b/apps/console/src/pages/organizations/risks/routes.ts index b35ef874b..9c0c0863b 100644 --- a/apps/console/src/pages/organizations/risks/routes.ts +++ b/apps/console/src/pages/organizations/risks/routes.ts @@ -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")), }, ], }, diff --git a/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx b/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx index d97ce734b..cc31cd0f1 100644 --- a/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx +++ b/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx @@ -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; +} + +export default function RiskScenariosPage(props: RiskScenariosPageProps) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); - const { risk } = useOutletContext<{ - risk: RiskDetailLayoutQuery$data["node"]; - }>(); - const data = useFragment(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, diff --git a/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPageLoader.tsx b/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPageLoader.tsx new file mode 100644 index 000000000..f67bd445e --- /dev/null +++ b/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPageLoader.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +}