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