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
|
||||
// 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
|
||||
? {
|
||||
|
||||
@@ -41,20 +41,17 @@ import type { RiskDetailLayoutQuery } from "#/__generated__/core/RiskDetailLayou
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { RisksConnectionKey } from "#/pages/organizations/risks/RisksPage";
|
||||
|
||||
import FormRiskDialog from "./FormRiskDialog";
|
||||
|
||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||
import { FormRiskDialog } from "./_components/FormRiskDialog";
|
||||
|
||||
export const riskDetailLayoutQuery = graphql`
|
||||
query RiskDetailLayoutQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
id
|
||||
name
|
||||
description
|
||||
treatment
|
||||
owner {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
note
|
||||
@@ -77,31 +74,7 @@ export const riskDetailLayoutQuery = graphql`
|
||||
}
|
||||
canUpdate: permission(action: "core:risk:update")
|
||||
canDelete: permission(action: "core:risk:delete")
|
||||
canCreateDocumentMapping: permission(
|
||||
action: "core:risk:create-document-mapping"
|
||||
)
|
||||
canDeleteDocumentMapping: permission(
|
||||
action: "core:risk:delete-document-mapping"
|
||||
)
|
||||
canCreateMeasureMapping: permission(
|
||||
action: "core:risk:create-measure-mapping"
|
||||
)
|
||||
canDeleteMeasureMapping: permission(
|
||||
action: "core:risk:delete-measure-mapping"
|
||||
)
|
||||
canCreateObligationMapping: permission(
|
||||
action: "core:risk:create-obligation-mapping"
|
||||
)
|
||||
canDeleteObligationMapping: permission(
|
||||
action: "core:risk:delete-obligation-mapping"
|
||||
)
|
||||
...useRiskFormFragment
|
||||
...RiskOverviewTabFragment
|
||||
...RiskMeasuresTabFragment
|
||||
...RiskDocumentsTabFragment
|
||||
...RiskControlsTabFragment
|
||||
...RiskObligationsTabFragment
|
||||
...RiskScenariosPageFragment
|
||||
...FormRiskDialog_risk
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,11 +91,11 @@ const deleteRiskMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
interface RiskDetailLayoutProps {
|
||||
queryRef: PreloadedQuery<RiskDetailLayoutQuery>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RiskDetailLayout(props: Props) {
|
||||
export default function RiskDetailLayout(props: RiskDetailLayoutProps) {
|
||||
const { riskId } = useParams<{
|
||||
riskId: string;
|
||||
}>();
|
||||
@@ -134,14 +107,15 @@ export default function RiskDetailLayout(props: Props) {
|
||||
}
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const { node: risk } = usePreloadedQuery(
|
||||
riskDetailLayoutQuery,
|
||||
props.queryRef,
|
||||
);
|
||||
const data = usePreloadedQuery(riskDetailLayoutQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const risk = data.node;
|
||||
|
||||
const [deleteRisk] = useMutation<RiskDetailLayoutDeleteMutation>(deleteRiskMutation);
|
||||
|
||||
usePageTitle(risk.name ?? "Risk detail");
|
||||
usePageTitle(risk.name);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const onDelete = () => {
|
||||
@@ -188,7 +162,6 @@ export default function RiskDetailLayout(props: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
@@ -209,7 +182,7 @@ export default function RiskDetailLayout(props: Props) {
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
risk={{ id: riskId, ...risk }}
|
||||
risk={risk}
|
||||
/>
|
||||
)}
|
||||
{risk.canDelete && (
|
||||
@@ -251,7 +224,7 @@ export default function RiskDetailLayout(props: Props) {
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{ risk }} />
|
||||
<Outlet />
|
||||
|
||||
<Drawer>
|
||||
<PropertyRow label={__("Owner")}>
|
||||
|
||||
@@ -12,50 +12,37 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { getTreatment, sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPageTextLine,
|
||||
IconPencil,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RisksChart,
|
||||
SeverityBadge,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import {
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
useMutation,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { RisksPageDeleteMutation } from "#/__generated__/core/RisksPageDeleteMutation.graphql";
|
||||
import type { RisksPageFragment$data, RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql";
|
||||
import type { RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql";
|
||||
import type { RisksPageQuery } from "#/__generated__/core/RisksPageQuery.graphql";
|
||||
import type { RisksPageRefetchQuery } from "#/__generated__/core/RisksPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { NodeOf } from "#/types";
|
||||
|
||||
import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog";
|
||||
import FormRiskDialog from "./FormRiskDialog";
|
||||
|
||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||
import { FormRiskDialog } from "./_components/FormRiskDialog";
|
||||
import { PublishRiskListDialog } from "./_components/PublishRiskListDialog";
|
||||
import { RiskRow } from "./_components/RiskRow";
|
||||
|
||||
export const risksPageQuery = graphql`
|
||||
query RisksPageQuery($organizationId: ID!) {
|
||||
@@ -83,8 +70,6 @@ const risksFragment = graphql`
|
||||
canPublishRisk: permission(action: "core:risk:publish")
|
||||
risksDocument {
|
||||
id
|
||||
currentPublishedMajor
|
||||
currentPublishedMinor
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
@@ -101,45 +86,26 @@ const risksFragment = graphql`
|
||||
node {
|
||||
id
|
||||
name
|
||||
category
|
||||
treatment
|
||||
owner {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
inherentLikelihood
|
||||
inherentImpact
|
||||
residualLikelihood
|
||||
residualImpact
|
||||
inherentRiskScore
|
||||
residualRiskScore
|
||||
canUpdate: permission(action: "core:risk:update")
|
||||
canDelete: permission(action: "core:risk:delete")
|
||||
...useRiskFormFragment
|
||||
...RiskRow_risk
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteRiskMutation = graphql`
|
||||
mutation RisksPageDeleteMutation(
|
||||
$input: DeleteRiskInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteRisk(input: $input) {
|
||||
deletedRiskId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RisksConnectionKey = "RisksPage_risks";
|
||||
|
||||
type Props = {
|
||||
interface RisksPageProps {
|
||||
queryRef: PreloadedQuery<RisksPageQuery>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RisksPage(props: Props) {
|
||||
export default function RisksPage(props: RisksPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const navigate = useNavigate();
|
||||
@@ -156,6 +122,22 @@ export default function RisksPage(props: Props) {
|
||||
const risks = fragmentData.risks?.edges.map(edge => edge.node) ?? [];
|
||||
const connectionId = fragmentData.risks.__id;
|
||||
|
||||
const chartRisks = risks.map(({
|
||||
id,
|
||||
name,
|
||||
inherentLikelihood,
|
||||
inherentImpact,
|
||||
residualLikelihood,
|
||||
residualImpact,
|
||||
}) => ({
|
||||
id,
|
||||
name,
|
||||
inherentLikelihood,
|
||||
inherentImpact,
|
||||
residualLikelihood,
|
||||
residualImpact,
|
||||
}));
|
||||
|
||||
const refetch = ({
|
||||
order,
|
||||
}: {
|
||||
@@ -181,8 +163,9 @@ export default function RisksPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Risks"));
|
||||
|
||||
const hasAnyAction
|
||||
= risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
|
||||
const hasAnyAction = risks.some(
|
||||
({ canDelete, canUpdate }) => canUpdate || canDelete,
|
||||
);
|
||||
|
||||
const defaultApproverIds
|
||||
= risksDocument?.defaultApprovers?.map(a => a.id) ?? [];
|
||||
@@ -236,12 +219,12 @@ export default function RisksPage(props: Props) {
|
||||
<RisksChart
|
||||
organizationId={organizationId}
|
||||
type="inherent"
|
||||
risks={risks}
|
||||
risks={chartRisks}
|
||||
/>
|
||||
<RisksChart
|
||||
organizationId={organizationId}
|
||||
type="residual"
|
||||
risks={risks}
|
||||
risks={chartRisks}
|
||||
/>
|
||||
</div>
|
||||
<SortableTable {...pagination} refetch={refetch}>
|
||||
@@ -261,12 +244,11 @@ export default function RisksPage(props: Props) {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{risks?.map(risk => (
|
||||
{risks.map(risk => (
|
||||
<RiskRow
|
||||
risk={risk}
|
||||
key={risk.id}
|
||||
riskKey={risk}
|
||||
connectionId={connectionId}
|
||||
organizationId={organizationId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
@@ -275,88 +257,3 @@ export default function RisksPage(props: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RowProps = {
|
||||
risk: NodeOf<RisksPageFragment$data["risks"]>;
|
||||
connectionId: string;
|
||||
organizationId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function RiskRow(props: RowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const { risk, connectionId, organizationId } = props;
|
||||
const [deleteRisk] = useMutation<RisksPageDeleteMutation>(deleteRiskMutation);
|
||||
const confirm = useConfirm();
|
||||
const onDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
void deleteRisk({
|
||||
variables: {
|
||||
input: { riskId: risk.id },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: () => resolve(),
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the risk \"%s\". This action cannot be undone.",
|
||||
),
|
||||
risk.name,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
const formDialogRef = useDialogRef();
|
||||
|
||||
const riskUrl = `/organizations/${organizationId}/risks/${risk.id}/overview`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormRiskDialog
|
||||
ref={formDialogRef}
|
||||
risk={risk}
|
||||
connection={connectionId}
|
||||
/>
|
||||
<Tr to={riskUrl}>
|
||||
<Td>{risk.name}</Td>
|
||||
<Td>{risk.category}</Td>
|
||||
<Td>{getTreatment(__, risk.treatment)}</Td>
|
||||
<Td>
|
||||
<SeverityBadge score={risk.inherentRiskScore} />
|
||||
</Td>
|
||||
<Td>
|
||||
<SeverityBadge score={risk.residualRiskScore} />
|
||||
</Td>
|
||||
<Td>{risk.owner?.fullName || __("Unassigned")}</Td>
|
||||
{props.hasAnyAction && (
|
||||
<Td noLink className="text-end">
|
||||
<ActionDropdown>
|
||||
{risk.canUpdate && (
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => formDialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
|
||||
{risk.canDelete && (
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { getRiskImpacts, getRiskLikelihoods } from "@probo/helpers";
|
||||
import { formatError, getRiskImpacts, getRiskLikelihoods, type GraphQLError } from "@probo/helpers";
|
||||
import { useToggle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
@@ -30,11 +30,14 @@ import {
|
||||
Select,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import type { FieldErrors } from "react-hook-form";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { FormRiskDialog_risk$key } from "#/__generated__/core/FormRiskDialog_risk.graphql";
|
||||
import type { FormRiskDialogMutation } from "#/__generated__/core/FormRiskDialogMutation.graphql";
|
||||
import type { FormRiskDialogUpdateRiskMutation } from "#/__generated__/core/FormRiskDialogUpdateRiskMutation.graphql";
|
||||
import {
|
||||
@@ -45,20 +48,18 @@ import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||
import {
|
||||
type RiskData,
|
||||
type RiskForm,
|
||||
type RiskKey,
|
||||
useRiskForm,
|
||||
} from "#/hooks/forms/useRiskForm";
|
||||
import { useFetchQuery } from "#/hooks/useFetchQuery";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
type Props = {
|
||||
interface FormRiskDialogProps {
|
||||
trigger?: ReactNode;
|
||||
risk?: RiskKey;
|
||||
risk?: FormRiskDialog_risk$key;
|
||||
connection?: string;
|
||||
ref?: ReturnType<typeof useDialogRef>;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
type RiskTemplate = {
|
||||
category: string;
|
||||
@@ -66,6 +67,26 @@ type RiskTemplate = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
const formRiskFragment = graphql`
|
||||
fragment FormRiskDialog_risk on Risk {
|
||||
id
|
||||
name
|
||||
category
|
||||
description
|
||||
treatment
|
||||
inherentLikelihood
|
||||
inherentImpact
|
||||
residualLikelihood
|
||||
residualImpact
|
||||
inherentRiskScore
|
||||
residualRiskScore
|
||||
note
|
||||
owner {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createRiskMutation = graphql`
|
||||
mutation FormRiskDialogMutation(
|
||||
$input: CreateRiskInput!
|
||||
@@ -74,7 +95,7 @@ const createRiskMutation = graphql`
|
||||
createRisk(input: $input) {
|
||||
riskEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
...useRiskFormFragment
|
||||
...FormRiskDialog_risk
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,44 +106,60 @@ const updateRiskMutation = graphql`
|
||||
mutation FormRiskDialogUpdateRiskMutation($input: UpdateRiskInput!) {
|
||||
updateRisk(input: $input) {
|
||||
risk {
|
||||
...useRiskFormFragment
|
||||
...FormRiskDialog_risk
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Dialog to create or update a risk
|
||||
*/
|
||||
export default function FormRiskDialog({
|
||||
export function FormRiskDialog({
|
||||
trigger,
|
||||
risk,
|
||||
risk: riskKey,
|
||||
connection,
|
||||
ref: refProps,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
}: FormRiskDialogProps) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const dialogRef = useDialogRef();
|
||||
const ref = refProps ?? dialogRef;
|
||||
|
||||
const risk = useFragment(formRiskFragment, riskKey ?? null);
|
||||
const formDefaults = risk
|
||||
? {
|
||||
id: risk.id,
|
||||
name: risk.name,
|
||||
category: risk.category,
|
||||
description: risk.description,
|
||||
treatment: risk.treatment,
|
||||
inherentLikelihood: risk.inherentLikelihood,
|
||||
inherentImpact: risk.inherentImpact,
|
||||
residualLikelihood: risk.residualLikelihood,
|
||||
residualImpact: risk.residualImpact,
|
||||
inherentRiskScore: risk.inherentRiskScore,
|
||||
residualRiskScore: risk.residualRiskScore,
|
||||
note: risk.note,
|
||||
owner: risk.owner,
|
||||
}
|
||||
: undefined;
|
||||
const { control, handleSubmit, setValue, register, watch, formState, reset }
|
||||
= useRiskForm(risk);
|
||||
= useRiskForm(formDefaults);
|
||||
const errors = formState.errors ?? {};
|
||||
const [createRisk, isLoadingCreate]
|
||||
= useMutationWithToasts<FormRiskDialogMutation>(createRiskMutation);
|
||||
const [updateRisk, isLoadingUpdate]
|
||||
= useMutationWithToasts<FormRiskDialogUpdateRiskMutation>(updateRiskMutation);
|
||||
const isLoading = isLoadingCreate || isLoadingUpdate;
|
||||
const [createRisk, isCreating]
|
||||
= useMutation<FormRiskDialogMutation>(createRiskMutation);
|
||||
const [updateRisk, isUpdating]
|
||||
= useMutation<FormRiskDialogUpdateRiskMutation>(updateRiskMutation);
|
||||
const isLoading = isCreating || isUpdating;
|
||||
|
||||
const onTemplateChange = (risk: RiskTemplate) => {
|
||||
setValue("name", risk.name);
|
||||
setValue("description", risk.description);
|
||||
const onTemplateChange = (template: RiskTemplate) => {
|
||||
setValue("name", template.name);
|
||||
setValue("description", template.description);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: RiskData) => {
|
||||
const onSubmit = (data: RiskData) => {
|
||||
if (risk) {
|
||||
await updateRisk({
|
||||
updateRisk({
|
||||
variables: {
|
||||
input: {
|
||||
id: risk.id,
|
||||
@@ -130,15 +167,28 @@ export default function FormRiskDialog({
|
||||
description: data.description || null,
|
||||
},
|
||||
},
|
||||
successMessage: __("Risk updated successfully."),
|
||||
errorMessage: __("Failed to update risk"),
|
||||
onSuccess: () => {
|
||||
onCompleted() {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Risk updated successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
ref?.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to update risk"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await createRisk({
|
||||
createRisk({
|
||||
variables: {
|
||||
input: {
|
||||
...data,
|
||||
@@ -147,13 +197,26 @@ export default function FormRiskDialog({
|
||||
},
|
||||
connections: [connection!],
|
||||
},
|
||||
successMessage: __("Risk created successfully."),
|
||||
errorMessage: __("Failed to create risk"),
|
||||
onSuccess: () => {
|
||||
onCompleted() {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Risk created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
ref?.current?.close();
|
||||
reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create risk"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -171,7 +234,6 @@ export default function FormRiskDialog({
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent className="grid grid-cols-[1fr_420px]">
|
||||
{/* Main form */}
|
||||
<div className="py-8 px-12 space-y-6">
|
||||
<TemplateSelector
|
||||
onChange={onTemplateChange}
|
||||
@@ -209,7 +271,6 @@ export default function FormRiskDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Properties form */}
|
||||
<div className="py-5 px-6 bg-subtle">
|
||||
<Label>{__("Properties")}</Label>
|
||||
|
||||
@@ -25,8 +25,13 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useMemo, useState } from "react";
|
||||
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
|
||||
import { cloneElement, isValidElement, type MouseEvent, type ReactElement, type ReactNode, Suspense, useMemo, useState } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
useQueryLoader,
|
||||
} from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type {
|
||||
@@ -71,39 +76,54 @@ const scenariosFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
interface LinkScenarioDialogProps {
|
||||
children: ReactNode;
|
||||
connectionId: string;
|
||||
disabled?: boolean;
|
||||
linkedScenarios?: { id: string }[];
|
||||
onLink: (scenarioId: string) => void;
|
||||
onUnlink: (scenarioId: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkScenarioDialog({ children, ...props }: Props) {
|
||||
export function LinkScenarioDialog({ children, ...props }: LinkScenarioDialogProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<LinkScenarioDialogQuery>(scenariosQuery);
|
||||
|
||||
const trigger = isValidElement(children)
|
||||
? cloneElement(children as ReactElement<{ onClick?: (e: MouseEvent) => void }>, {
|
||||
onClick: (e: MouseEvent) => {
|
||||
(children as ReactElement<{ onClick?: (e: MouseEvent) => void }>).props.onClick?.(e);
|
||||
loadQuery({ organizationId }, { fetchPolicy: "network-only" });
|
||||
},
|
||||
})
|
||||
: children;
|
||||
|
||||
return (
|
||||
<Dialog trigger={children} title={__("Link scenarios")}>
|
||||
<Dialog trigger={trigger} title={__("Link scenarios")}>
|
||||
<DialogContent>
|
||||
<Suspense fallback={<Spinner centered />}>
|
||||
<LinkScenarioDialogContent {...props} />
|
||||
</Suspense>
|
||||
{queryRef
|
||||
? (
|
||||
<Suspense fallback={<Spinner centered />}>
|
||||
<LinkScenarioDialogContent queryRef={queryRef} {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
: (
|
||||
<Spinner centered />
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter exitLabel={__("Close")} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkScenarioDialogContent(props: Omit<Props, "children">) {
|
||||
const organizationId = useOrganizationId();
|
||||
const query = useLazyLoadQuery<LinkScenarioDialogQuery>(
|
||||
scenariosQuery,
|
||||
{
|
||||
organizationId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
type ContentProps = Omit<LinkScenarioDialogProps, "children"> & {
|
||||
queryRef: PreloadedQuery<LinkScenarioDialogQuery>;
|
||||
};
|
||||
|
||||
function LinkScenarioDialogContent(props: ContentProps) {
|
||||
const query = usePreloadedQuery(scenariosQuery, props.queryRef);
|
||||
const { data, loadNext, hasNext, isLoadingNext }
|
||||
= usePaginationFragment<LinkScenarioDialogQuery_fragment, LinkScenarioDialogFragment$key>(
|
||||
scenariosFragment,
|
||||
|
||||
@@ -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();
|
||||
@@ -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 { Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import { graphql, usePaginationFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import {
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
|
||||
import type { RiskControlsTabControlsQuery } from "#/__generated__/core/RiskControlsTabControlsQuery.graphql";
|
||||
import type { RiskControlsTabFragment$key } from "#/__generated__/core/RiskControlsTabFragment.graphql";
|
||||
import type { RiskControlsPage_risk$key } from "#/__generated__/core/RiskControlsPage_risk.graphql";
|
||||
import type { RiskControlsPageQuery } from "#/__generated__/core/RiskControlsPageQuery.graphql";
|
||||
import type { RiskControlsPageRefetchQuery } from "#/__generated__/core/RiskControlsPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export const controlsFragment = graphql`
|
||||
fragment RiskControlsTabFragment on Risk
|
||||
export const riskControlsPageQuery = graphql`
|
||||
query RiskControlsPageQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
...RiskControlsPage_risk
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const controlsFragment = graphql`
|
||||
fragment RiskControlsPage_risk on Risk
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 20 }
|
||||
after: { type: "CursorKey" }
|
||||
@@ -33,7 +49,7 @@ export const controlsFragment = graphql`
|
||||
order: { type: "ControlOrder", defaultValue: null }
|
||||
filter: { type: "ControlFilter", defaultValue: null }
|
||||
)
|
||||
@refetchable(queryName: "RiskControlsTabControlsQuery") {
|
||||
@refetchable(queryName: "RiskControlsPageRefetchQuery") {
|
||||
id
|
||||
controls(
|
||||
first: $first
|
||||
@@ -42,7 +58,7 @@ export const controlsFragment = graphql`
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: $filter
|
||||
) @connection(key: "RiskControlsTab_controls") {
|
||||
) @connection(key: "RiskControlsPage_controls") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -57,17 +73,23 @@ export const controlsFragment = graphql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default function RiskControlsTab() {
|
||||
const { risk } = useOutletContext<{
|
||||
risk: RiskControlsTabFragment$key & { id: string };
|
||||
}>();
|
||||
|
||||
interface RiskControlsPageProps {
|
||||
queryRef: PreloadedQuery<RiskControlsPageQuery>;
|
||||
}
|
||||
|
||||
export default function RiskControlsPage(props: RiskControlsPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const pagination = usePaginationFragment<
|
||||
RiskControlsTabControlsQuery,
|
||||
RiskControlsTabFragment$key
|
||||
>(controlsFragment, risk);
|
||||
const controls = pagination.data.controls.edges.map(edge => edge.node);
|
||||
const organizationId = useOrganizationId();
|
||||
const data = usePreloadedQuery(riskControlsPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const pagination = usePaginationFragment<
|
||||
RiskControlsPageRefetchQuery,
|
||||
RiskControlsPage_risk$key
|
||||
>(controlsFragment, data.node);
|
||||
const controls = pagination.data.controls.edges.map(edge => edge.node);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
@@ -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
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
|
||||
import type { RiskDocumentsTabFragment$key } from "#/__generated__/core/RiskDocumentsTabFragment.graphql";
|
||||
import type { RiskDocumentsPageQuery } from "#/__generated__/core/RiskDocumentsPageQuery.graphql";
|
||||
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
|
||||
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||
|
||||
export const documentsFragment = graphql`
|
||||
fragment RiskDocumentsTabFragment on Risk {
|
||||
id
|
||||
documents(first: 100) @connection(key: "Risk__documents") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedDocumentsCardFragment
|
||||
export const riskDocumentsPageQuery = graphql`
|
||||
query RiskDocumentsPageQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
id
|
||||
canCreateDocumentMapping: permission(
|
||||
action: "core:risk:create-document-mapping"
|
||||
)
|
||||
canDeleteDocumentMapping: permission(
|
||||
action: "core:risk:delete-document-mapping"
|
||||
)
|
||||
documents(first: 100) @connection(key: "RiskDocumentsPage_documents") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedDocumentsCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +45,7 @@ export const documentsFragment = graphql`
|
||||
`;
|
||||
|
||||
const attachDocumentMutation = graphql`
|
||||
mutation RiskDocumentsTabCreateMutation(
|
||||
mutation RiskDocumentsPageCreateMutation(
|
||||
$input: CreateRiskDocumentMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
@@ -51,8 +60,8 @@ const attachDocumentMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const detachDocumentMutation = graphql`
|
||||
mutation RiskDocumentsTabDetachMutation(
|
||||
const detachDocumentMutation = graphql`
|
||||
mutation RiskDocumentsPageDetachMutation(
|
||||
$input: DeleteRiskDocumentMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
@@ -62,23 +71,23 @@ export const detachDocumentMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RiskDocumentsTab() {
|
||||
const { risk } = useOutletContext<{
|
||||
risk: RiskDetailLayoutQuery$data["node"];
|
||||
}>();
|
||||
const data = useFragment<RiskDocumentsTabFragment$key>(
|
||||
documentsFragment,
|
||||
risk,
|
||||
);
|
||||
const connectionId = data.documents.__id;
|
||||
const documents = data.documents?.edges?.map(edge => edge.node) ?? [];
|
||||
interface RiskDocumentsPageProps {
|
||||
queryRef: PreloadedQuery<RiskDocumentsPageQuery>;
|
||||
}
|
||||
|
||||
const canLinkDocument = risk.canCreateDocumentMapping;
|
||||
const canUnlinkDocument = risk.canDeleteDocumentMapping;
|
||||
const readOnly = !canLinkDocument && !canUnlinkDocument;
|
||||
export default function RiskDocumentsPage(props: RiskDocumentsPageProps) {
|
||||
const data = usePreloadedQuery(riskDocumentsPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const risk = data.node;
|
||||
const connectionId = risk.documents.__id;
|
||||
const documents = risk.documents.edges.map(edge => edge.node);
|
||||
|
||||
const readOnly = !risk.canCreateDocumentMapping && !risk.canDeleteDocumentMapping;
|
||||
|
||||
const incrementOptions = {
|
||||
id: data.id,
|
||||
id: risk.id,
|
||||
node: "documents(first:0)",
|
||||
};
|
||||
const [detachDocument, isDetaching] = useMutationWithIncrement(
|
||||
@@ -103,7 +112,7 @@ export default function RiskDocumentsTab() {
|
||||
documents={documents}
|
||||
onAttach={attachDocument}
|
||||
onDetach={detachDocument}
|
||||
params={{ riskId: data.id }}
|
||||
params={{ riskId: risk.id }}
|
||||
connectionId={connectionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
@@ -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
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
|
||||
import type { RiskMeasuresTabFragment$key } from "#/__generated__/core/RiskMeasuresTabFragment.graphql";
|
||||
import type { RiskMeasuresPageQuery } from "#/__generated__/core/RiskMeasuresPageQuery.graphql";
|
||||
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
|
||||
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||
|
||||
export const measuresFragment = graphql`
|
||||
fragment RiskMeasuresTabFragment on Risk {
|
||||
id
|
||||
measures(first: 100) @connection(key: "Risk__measures") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedMeasuresCardFragment
|
||||
export const riskMeasuresPageQuery = graphql`
|
||||
query RiskMeasuresPageQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
id
|
||||
canCreateMeasureMapping: permission(
|
||||
action: "core:risk:create-measure-mapping"
|
||||
)
|
||||
canDeleteMeasureMapping: permission(
|
||||
action: "core:risk:delete-measure-mapping"
|
||||
)
|
||||
measures(first: 100) @connection(key: "RiskMeasuresPage_measures") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedMeasuresCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +45,7 @@ export const measuresFragment = graphql`
|
||||
`;
|
||||
|
||||
const attachMeasureMutation = graphql`
|
||||
mutation RiskMeasuresTabCreateMutation(
|
||||
mutation RiskMeasuresPageCreateMutation(
|
||||
$input: CreateRiskMeasureMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
@@ -51,8 +60,8 @@ const attachMeasureMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const detachMeasureMutation = graphql`
|
||||
mutation RiskMeasuresTabDetachMutation(
|
||||
const detachMeasureMutation = graphql`
|
||||
mutation RiskMeasuresPageDetachMutation(
|
||||
$input: DeleteRiskMeasureMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
@@ -62,20 +71,23 @@ export const detachMeasureMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RiskMeasuresTab() {
|
||||
const { risk } = useOutletContext<{
|
||||
risk: RiskDetailLayoutQuery$data["node"];
|
||||
}>();
|
||||
const data = useFragment<RiskMeasuresTabFragment$key>(measuresFragment, risk);
|
||||
const connectionId = data.measures.__id;
|
||||
const measures = data.measures?.edges?.map(edge => edge.node) ?? [];
|
||||
interface RiskMeasuresPageProps {
|
||||
queryRef: PreloadedQuery<RiskMeasuresPageQuery>;
|
||||
}
|
||||
|
||||
const canLinkMeasure = risk.canCreateMeasureMapping;
|
||||
const canUnlinkMeasure = risk.canDeleteMeasureMapping;
|
||||
const readOnly = !canLinkMeasure && !canUnlinkMeasure;
|
||||
export default function RiskMeasuresPage(props: RiskMeasuresPageProps) {
|
||||
const data = usePreloadedQuery(riskMeasuresPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const risk = data.node;
|
||||
const connectionId = risk.measures.__id;
|
||||
const measures = risk.measures.edges.map(edge => edge.node);
|
||||
|
||||
const readOnly = !risk.canCreateMeasureMapping && !risk.canDeleteMeasureMapping;
|
||||
|
||||
const incrementOptions = {
|
||||
id: data.id,
|
||||
id: risk.id,
|
||||
node: "measures(first:0)",
|
||||
};
|
||||
const [detachMeasure, isDetaching] = useMutationWithIncrement(
|
||||
@@ -100,7 +112,7 @@ export default function RiskMeasuresTab() {
|
||||
measures={measures}
|
||||
onAttach={attachMeasure}
|
||||
onDetach={detachMeasure}
|
||||
params={{ riskId: data.id }}
|
||||
params={{ riskId: risk.id }}
|
||||
connectionId={connectionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
@@ -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
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
|
||||
import type { RiskObligationsTabFragment$key } from "#/__generated__/core/RiskObligationsTabFragment.graphql";
|
||||
import type { RiskObligationsPageQuery } from "#/__generated__/core/RiskObligationsPageQuery.graphql";
|
||||
import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard";
|
||||
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||
|
||||
export const obligationsFragment = graphql`
|
||||
fragment RiskObligationsTabFragment on Risk {
|
||||
id
|
||||
obligations(first: 100) @connection(key: "Risk__obligations") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedObligationsCardFragment
|
||||
export const riskObligationsPageQuery = graphql`
|
||||
query RiskObligationsPageQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
id
|
||||
canCreateObligationMapping: permission(
|
||||
action: "core:risk:create-obligation-mapping"
|
||||
)
|
||||
canDeleteObligationMapping: permission(
|
||||
action: "core:risk:delete-obligation-mapping"
|
||||
)
|
||||
obligations(first: 100) @connection(key: "RiskObligationsPage_obligations") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedObligationsCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +45,7 @@ export const obligationsFragment = graphql`
|
||||
`;
|
||||
|
||||
const attachObligationMutation = graphql`
|
||||
mutation RiskObligationsTabCreateMutation(
|
||||
mutation RiskObligationsPageCreateMutation(
|
||||
$input: CreateRiskObligationMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
@@ -51,8 +60,8 @@ const attachObligationMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const detachObligationMutation = graphql`
|
||||
mutation RiskObligationsTabDetachMutation(
|
||||
const detachObligationMutation = graphql`
|
||||
mutation RiskObligationsPageDetachMutation(
|
||||
$input: DeleteRiskObligationMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
@@ -62,23 +71,24 @@ export const detachObligationMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RiskObligationsTab() {
|
||||
const { risk } = useOutletContext<{
|
||||
risk: RiskDetailLayoutQuery$data["node"];
|
||||
}>();
|
||||
const data = useFragment<RiskObligationsTabFragment$key>(
|
||||
obligationsFragment,
|
||||
risk,
|
||||
);
|
||||
const connectionId = data.obligations.__id;
|
||||
const obligations = data.obligations?.edges?.map(edge => edge.node) ?? [];
|
||||
interface RiskObligationsPageProps {
|
||||
queryRef: PreloadedQuery<RiskObligationsPageQuery>;
|
||||
}
|
||||
|
||||
const canLinkObligation = risk.canCreateObligationMapping;
|
||||
const canUnlinkObligation = risk.canDeleteObligationMapping;
|
||||
const readOnly = !canLinkObligation && !canUnlinkObligation;
|
||||
export default function RiskObligationsPage(props: RiskObligationsPageProps) {
|
||||
const data = usePreloadedQuery(riskObligationsPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const risk = data.node;
|
||||
const connectionId = risk.obligations.__id;
|
||||
const obligations = risk.obligations.edges.map(edge => edge.node);
|
||||
|
||||
const readOnly
|
||||
= !risk.canCreateObligationMapping && !risk.canDeleteObligationMapping;
|
||||
|
||||
const incrementOptions = {
|
||||
id: data.id,
|
||||
id: risk.id,
|
||||
node: "obligations(first:0)",
|
||||
};
|
||||
const [detachObligation, isDetaching] = useMutationWithIncrement(
|
||||
@@ -103,7 +113,7 @@ export default function RiskObligationsTab() {
|
||||
obligations={obligations}
|
||||
onAttach={attachObligation}
|
||||
onDetach={detachObligation}
|
||||
params={{ riskId: data.id }}
|
||||
params={{ riskId: risk.id }}
|
||||
connectionId={connectionId}
|
||||
variant="table"
|
||||
readOnly={readOnly}
|
||||
@@ -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.
|
||||
|
||||
import { RiskOverview } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { RiskOverviewTabFragment$key } from "#/__generated__/core/RiskOverviewTabFragment.graphql";
|
||||
import type { RiskOverviewPageQuery } from "#/__generated__/core/RiskOverviewPageQuery.graphql";
|
||||
|
||||
const overviewFragment = graphql`
|
||||
fragment RiskOverviewTabFragment on Risk {
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
inherentLikelihood
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
inherentImpact
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
residualLikelihood
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
residualImpact
|
||||
export const riskOverviewPageQuery = graphql`
|
||||
query RiskOverviewPageQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
inherentLikelihood
|
||||
inherentImpact
|
||||
residualLikelihood
|
||||
residualImpact
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RiskOverviewTab() {
|
||||
const { risk: key } = useOutletContext<{
|
||||
risk: RiskOverviewTabFragment$key;
|
||||
}>();
|
||||
interface RiskOverviewPageProps {
|
||||
queryRef: PreloadedQuery<RiskOverviewPageQuery>;
|
||||
}
|
||||
|
||||
const risk = useFragment(overviewFragment, key);
|
||||
export default function RiskOverviewPage(props: RiskOverviewPageProps) {
|
||||
const data = usePreloadedQuery(riskOverviewPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const { inherentLikelihood, inherentImpact, residualLikelihood, residualImpact }
|
||||
= data.node;
|
||||
const risk = {
|
||||
inherentLikelihood,
|
||||
inherentImpact,
|
||||
residualLikelihood,
|
||||
residualImpact,
|
||||
};
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RiskOverview type="inherent" risk={risk} />
|
||||
@@ -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",
|
||||
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")),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,30 +25,33 @@ import {
|
||||
Tr,
|
||||
TrButton,
|
||||
} from "@probo/ui";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql";
|
||||
import type { RiskScenariosPageFragment$key } from "#/__generated__/core/RiskScenariosPageFragment.graphql";
|
||||
import type { RiskScenariosPageLinkMutation } from "#/__generated__/core/RiskScenariosPageLinkMutation.graphql";
|
||||
import type { RiskScenariosPageQuery } from "#/__generated__/core/RiskScenariosPageQuery.graphql";
|
||||
import type { RiskScenariosPageUnlinkMutation } from "#/__generated__/core/RiskScenariosPageUnlinkMutation.graphql";
|
||||
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { LinkScenarioDialog } from "../_components/LinkScenarioDialog";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment RiskScenariosPageFragment on Risk {
|
||||
id
|
||||
scenarios(first: 100)
|
||||
@connection(key: "RiskScenariosPage_scenarios", filters: []) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
scope { riskAssessmentId }
|
||||
export const riskScenariosPageQuery = graphql`
|
||||
query RiskScenariosPageQuery($riskId: ID!) {
|
||||
node(id: $riskId) {
|
||||
__typename
|
||||
... on Risk {
|
||||
id
|
||||
scenarios(first: 100)
|
||||
@connection(key: "RiskScenariosPage_scenarios", filters: []) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
scope { riskAssessmentId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,16 +87,21 @@ const unlinkMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RiskScenariosPage() {
|
||||
interface RiskScenariosPageProps {
|
||||
queryRef: PreloadedQuery<RiskScenariosPageQuery>;
|
||||
}
|
||||
|
||||
export default function RiskScenariosPage(props: RiskScenariosPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { risk } = useOutletContext<{
|
||||
risk: RiskDetailLayoutQuery$data["node"];
|
||||
}>();
|
||||
const data = useFragment<RiskScenariosPageFragment$key>(fragment, risk);
|
||||
const scenarios = data.scenarios.edges.map(e => e.node);
|
||||
const connectionId = data.scenarios.__id;
|
||||
const riskId = data.id;
|
||||
const data = usePreloadedQuery(riskScenariosPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "Risk") {
|
||||
throw new Error("Risk not found");
|
||||
}
|
||||
const risk = data.node;
|
||||
const scenarios = risk.scenarios.edges.map(e => e.node);
|
||||
const connectionId = risk.scenarios.__id;
|
||||
const riskId = risk.id;
|
||||
|
||||
const incrementOptions = {
|
||||
id: riskId,
|
||||
|
||||
@@ -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