Add measure-document linking

Introduce the ability to link measures to documents, following the
existing pattern used by controls and risks. This includes:

- Database migration for measures_documents join table
- Coredata MeasureDocument struct with insert/delete operations
- Document service methods for listing/counting by measure ID
- Measure service CreateDocumentMapping/DeleteDocumentMapping methods
- Cleanup of measure-document mappings on document archive
- GraphQL mutations, inputs, payloads, and Measure.documents field
- DocumentConnection.TotalCount support for measure resolver
- MCP linkMeasure/unlinkMeasure updated to support documents
- MCP listMeasureDocuments tool
- Frontend MeasureDocumentsTab with LinkedDocumentsCard integration
- Authorization actions for measure document mapping
- E2e tests for measure document mapping

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-03 09:38:38 +02:00
parent 8de7add203
commit ef2e99d86c
16 changed files with 903 additions and 50 deletions

View File

@@ -19,8 +19,6 @@ import type { MeasureGraphDeleteMutation } from "#/__generated__/core/MeasureGra
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const MeasureConnectionKey = "MeasuresPage_measures";
const deleteMeasureMutation = graphql`
@@ -46,36 +44,6 @@ export function useDeleteMeasureMutation() {
);
}
export const measureNodeQuery = graphql`
query MeasureGraphNodeQuery($measureId: ID!) {
node(id: $measureId) {
... on Measure {
id
name
description
state
category
canUpdate: permission(action: "core:measure:update")
canDelete: permission(action: "core:measure:delete")
canListTasks: permission(action: "core:task:list")
evidencesInfos: evidences(first: 0) {
totalCount
}
risksInfos: risks(first: 0) {
totalCount
}
controlsInfos: controls(first: 0) {
totalCount
}
...MeasureRisksTabFragment
...MeasureControlsTabFragment
...MeasureFormDialogMeasureFragment
...MeasureEvidencesTabFragment
}
}
}
`;
const measureUpdateMutation = graphql`
mutation MeasureGraphUpdateMutation($input: UpdateMeasureInput!) {
updateMeasure(input: $input) {

View File

@@ -26,6 +26,7 @@ import {
DropdownItem,
IconCheckmark1,
IconFrame2,
IconPageCheck,
IconPageTextLine,
IconPencil,
IconTrashCan,
@@ -50,17 +51,58 @@ import {
} from "react-relay";
import { Outlet, useNavigate, useParams } from "react-router";
import type { MeasureDetailPageNodeQuery } from "#/__generated__/core/MeasureDetailPageNodeQuery.graphql";
import type { MeasureDetailPageTasksCountQuery } from "#/__generated__/core/MeasureDetailPageTasksCountQuery.graphql";
import type { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql";
import {
MeasureConnectionKey,
measureNodeQuery,
useDeleteMeasureMutation,
useUpdateMeasure,
} from "#/hooks/graph/MeasureGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import MeasureFormDialog from "./dialog/MeasureFormDialog";
import { controlsFragment } from "./tabs/MeasureControlsTab";
import { documentsFragment } from "./tabs/MeasureDocumentsTab";
import { evidencesFragment } from "./tabs/MeasureEvidencesTab";
import { risksFragment } from "./tabs/MeasureRisksTab";
void controlsFragment;
void documentsFragment;
void evidencesFragment;
void risksFragment;
export const measureNodeQuery = graphql`
query MeasureDetailPageNodeQuery($measureId: ID!) {
node(id: $measureId) {
... on Measure {
name
description
state
category
canUpdate: permission(action: "core:measure:update")
canDelete: permission(action: "core:measure:delete")
canListTasks: permission(action: "core:task:list")
evidencesInfos: evidences(first: 0) {
totalCount
}
risksInfos: risks(first: 0) {
totalCount
}
controlsInfos: controls(first: 0) {
totalCount
}
documentsInfos: documents(first: 0) {
totalCount
}
...MeasureRisksTabFragment
...MeasureControlsTabFragment
...MeasureDocumentsTabFragment
...MeasureFormDialogMeasureFragment
...MeasureEvidencesTabFragment
}
}
}
`;
const tasksCountQuery = graphql`
query MeasureDetailPageTasksCountQuery($measureId: ID!) {
@@ -84,7 +126,7 @@ function TasksCountBadge({ measureId }: { measureId: string }) {
}
type Props = {
queryRef: PreloadedQuery<MeasureGraphNodeQuery>;
queryRef: PreloadedQuery<MeasureDetailPageNodeQuery>;
};
export default function MeasureDetailPage(props: Props) {
@@ -106,6 +148,7 @@ export default function MeasureDetailPage(props: Props) {
const evidencesCount = measure.evidencesInfos?.totalCount ?? 0;
const controlsCount = measure.controlsInfos?.totalCount ?? 0;
const risksCount = measure.risksInfos?.totalCount ?? 0;
const documentsCount = measure.documentsInfos?.totalCount ?? 0;
const onDelete = () => {
const connectionId = ConnectionHandler.getConnectionID(
@@ -212,7 +255,7 @@ export default function MeasureDetailPage(props: Props) {
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/evidences`}
>
<IconPageTextLine size={20} />
<IconPageCheck size={20} />
{__("Evidences")}
<TabBadge>{evidencesCount}</TabBadge>
</TabLink>
@@ -241,6 +284,13 @@ export default function MeasureDetailPage(props: Props) {
{__("Risks")}
<TabBadge>{risksCount}</TabBadge>
</TabLink>
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/documents`}
>
<IconPageTextLine size={20} />
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
</Tabs>
<Outlet context={{ measure }} />

View File

@@ -0,0 +1,116 @@
// 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 { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import type { MeasureDocumentsTabFragment$key } from "#/__generated__/core/MeasureDocumentsTabFragment.graphql";
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const documentsFragment = graphql`
fragment MeasureDocumentsTabFragment on Measure {
id
canCreateDocumentMapping: permission(
action: "core:measure:create-document-mapping"
)
canDeleteDocumentMapping: permission(
action: "core:measure:delete-document-mapping"
)
documents(first: 100) @connection(key: "Measure__documents") {
__id
edges {
node {
id
...LinkedDocumentsCardFragment
}
}
}
}
`;
const attachDocumentMutation = graphql`
mutation MeasureDocumentsTabCreateMutation(
$input: CreateMeasureDocumentMappingInput!
$connections: [ID!]!
) {
createMeasureDocumentMapping(input: $input) {
documentEdge @prependEdge(connections: $connections) {
node {
id
...LinkedDocumentsCardFragment
}
}
}
}
`;
export const detachDocumentMutation = graphql`
mutation MeasureDocumentsTabDetachMutation(
$input: DeleteMeasureDocumentMappingInput!
$connections: [ID!]!
) {
deleteMeasureDocumentMapping(input: $input) {
deletedDocumentId @deleteEdge(connections: $connections)
}
}
`;
export default function MeasureDocumentsTab() {
const { measure } = useOutletContext<{
measure: MeasureDocumentsTabFragment$key;
}>();
const data = useFragment<MeasureDocumentsTabFragment$key>(
documentsFragment,
measure,
);
const connectionId = data.documents.__id;
const documents = data.documents?.edges?.map(edge => edge.node) ?? [];
const canLinkDocument = data.canCreateDocumentMapping;
const canUnlinkDocument = data.canDeleteDocumentMapping;
const readOnly = !canLinkDocument && !canUnlinkDocument;
const incrementOptions = {
id: data.id,
node: "documents(first:0)",
};
const [detachDocument, isDetaching] = useMutationWithIncrement(
detachDocumentMutation,
{
...incrementOptions,
value: -1,
},
);
const [attachDocument, isAttaching] = useMutationWithIncrement(
attachDocumentMutation,
{
...incrementOptions,
value: 1,
},
);
const isLoading = isDetaching || isAttaching;
return (
<LinkedDocumentsCard
disabled={isLoading}
documents={documents}
onAttach={attachDocument}
onDetach={detachDocument}
params={{ measureId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -22,11 +22,11 @@ import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { redirect } from "react-router";
import type { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql";
import type { MeasureDetailPageNodeQuery } from "#/__generated__/core/MeasureDetailPageNodeQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { measureNodeQuery } from "#/hooks/graph/MeasureGraph";
import { measureNodeQuery } from "#/pages/organizations/measures/MeasureDetailPage";
export const measureRoutes = [
{
@@ -41,7 +41,7 @@ export const measureRoutes = [
path: "measures/:measureId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ measureId }) =>
loadQuery<MeasureGraphNodeQuery>(coreEnvironment, measureNodeQuery, {
loadQuery<MeasureDetailPageNodeQuery>(coreEnvironment, measureNodeQuery, {
measureId: measureId,
}),
),
@@ -89,6 +89,14 @@ export const measureRoutes = [
import("#/pages/organizations/measures/tabs/MeasureEvidencesTab"),
),
},
{
path: "documents",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/measures/tabs/MeasureDocumentsTab"),
),
},
],
},
] satisfies AppRoute[];