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:
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user