Link measures to third parties

Add a many-to-many relationship between measures and third parties,
surfaced as a measures tab on the third party detail page and a third
parties tab on the measure detail page. Each side gets a paginated
list with a link/unlink dialog.

Also remove the right-hand drawer on the measure detail page and
expose the state as a badge in the page header, mirroring how the
compliance page surfaces its active flag.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-22 16:07:48 +02:00
parent b6b1e801b1
commit 6dfdd7ca49
35 changed files with 2109 additions and 54 deletions

View File

@@ -202,6 +202,9 @@ export const thirdPartyNodeQuery = graphql`
canUploadDPA: permission(
action: "core:thirdParty-data-privacy-agreement:upload"
)
measuresInfos: measures(first: 0) {
totalCount
}
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyContactsTabFragment
@@ -209,6 +212,7 @@ export const thirdPartyNodeQuery = graphql`
...ThirdPartyRiskAssessmentTabFragment
...ThirdPartyOverviewTabBusinessAssociateAgreementFragment
...ThirdPartyOverviewTabDataPrivacyAgreementFragment
...ThirdPartyMeasuresPageFragment
}
}
viewer {
@@ -221,7 +225,11 @@ export const thirdPartiesSelectQuery = graphql`
query ThirdPartyGraphSelectQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(first: 100, orderBy: { direction: ASC, field: NAME }) {
thirdParties(
first: 100
orderBy: { direction: ASC, field: NAME }
filter: { firstLevel: true }
) {
edges {
node {
id

View File

@@ -22,18 +22,17 @@ import {
ActionDropdown,
Breadcrumb,
Button,
Drawer,
DropdownItem,
IconCheckmark1,
IconFrame2,
IconPageCheck,
IconPageTextLine,
IconPencil,
IconStore,
IconTrashCan,
IconWarning,
Option,
PageHeader,
PropertyRow,
Select,
TabBadge,
TabLink,
@@ -65,11 +64,13 @@ import { controlsFragment } from "./tabs/MeasureControlsTab";
import { documentsFragment } from "./tabs/MeasureDocumentsTab";
import { evidencesFragment } from "./tabs/MeasureEvidencesTab";
import { risksFragment } from "./tabs/MeasureRisksTab";
import { thirdPartiesFragment } from "./third-parties/MeasureThirdPartiesPage";
void controlsFragment;
void documentsFragment;
void evidencesFragment;
void risksFragment;
void thirdPartiesFragment;
export const measureNodeQuery = graphql`
query MeasureDetailPageNodeQuery($measureId: ID!) {
@@ -94,11 +95,15 @@ export const measureNodeQuery = graphql`
documentsInfos: documents(first: 0) {
totalCount
}
thirdPartiesInfos: thirdParties(first: 0) {
totalCount
}
...MeasureRisksTabFragment
...MeasureControlsTabFragment
...MeasureDocumentsTabFragment
...MeasureFormDialogMeasureFragment
...MeasureEvidencesTabFragment
...MeasureThirdPartiesPageFragment
}
}
}
@@ -149,6 +154,7 @@ export default function MeasureDetailPage(props: Props) {
const controlsCount = measure.controlsInfos?.totalCount ?? 0;
const risksCount = measure.risksInfos?.totalCount ?? 0;
const documentsCount = measure.documentsInfos?.totalCount ?? 0;
const thirdPartiesCount = measure.thirdPartiesInfos?.totalCount ?? 0;
const onDelete = () => {
const connectionId = ConnectionHandler.getConnectionID(
@@ -215,6 +221,7 @@ export default function MeasureDetailPage(props: Props) {
/>
<PageHeader title={measure.name} description={measure.description}>
{!measure.canUpdate && <MeasureBadge state={measure.state!} />}
{measure.canUpdate && (
<>
<MeasureFormDialog measure={measure}>
@@ -291,15 +298,16 @@ export default function MeasureDetailPage(props: Props) {
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/third-parties`}
>
<IconStore size={20} />
{__("Third parties")}
<TabBadge>{thirdPartiesCount}</TabBadge>
</TabLink>
</Tabs>
<Outlet context={{ measure }} />
<Drawer>
<PropertyRow label={__("State")}>
<MeasureBadge state={measure.state!} />
</PropertyRow>
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,182 @@
// 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 { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
IconPlusLarge,
IconTrashCan,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
TrButton,
} from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { LinkedThirdPartiesCardFragment$key } from "#/__generated__/core/LinkedThirdPartiesCardFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { LinkedThirdPartiesDialog } from "./LinkedThirdPartiesDialog";
const linkedThirdPartyFragment = graphql`
fragment LinkedThirdPartiesCardFragment on ThirdParty {
id
name
category
websiteUrl
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
thirdPartyId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
thirdParties: (LinkedThirdPartiesCardFragment$key & { id: string })[];
params: Params;
disabled?: boolean;
connectionId: string;
onAttach: Mutation<Params>;
onDetach: Mutation<Params>;
readOnly?: boolean;
};
export function LinkedThirdPartiesCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const thirdParties = props.thirdParties;
const onAttach = (thirdPartyId: string) => {
props.onAttach({
variables: {
input: {
thirdPartyId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (thirdPartyId: string) => {
props.onDetach({
variables: {
input: {
thirdPartyId,
...props.params,
},
connections: [props.connectionId],
},
});
};
return (
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Category")}</Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{thirdParties.length === 0 && (
<Tr>
<Td
colSpan={props.readOnly ? 2 : 3}
className="text-center text-txt-secondary"
>
{__("No third parties linked")}
</Td>
</Tr>
)}
{thirdParties.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
onClick={onDetach}
readOnly={props.readOnly}
/>
))}
{!props.readOnly && (
<LinkedThirdPartiesDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedThirdParties={thirdParties}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3} icon={IconPlusLarge}>
{__("Link third party")}
</TrButton>
</LinkedThirdPartiesDialog>
)}
</Tbody>
</Table>
);
}
function ThirdPartyRow(props: {
thirdParty: LinkedThirdPartiesCardFragment$key & { id: string };
onClick: (thirdPartyId: string) => void;
readOnly?: boolean;
}) {
const thirdParty = useFragment(linkedThirdPartyFragment, props.thirdParty);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const logo = faviconUrl(thirdParty.websiteUrl);
return (
<Tr
to={`/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`}
>
<Td>
<span className="inline-flex gap-2 items-center">
{logo && (
<img
src={logo}
alt={thirdParty.name}
className="rounded h-5 w-5"
/>
)}
{thirdParty.name}
</span>
</Td>
<Td>
<Badge size="md">{thirdParty.category}</Badge>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(thirdParty.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -0,0 +1,232 @@
// 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 { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
Dialog,
DialogContent,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import {
type ReactNode,
type RefObject,
Suspense,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type {
LinkedThirdPartiesDialogFragment$data,
LinkedThirdPartiesDialogFragment$key,
} from "#/__generated__/core/LinkedThirdPartiesDialogFragment.graphql";
import type { LinkedThirdPartiesDialogQuery } from "#/__generated__/core/LinkedThirdPartiesDialogQuery.graphql";
import type { LinkedThirdPartiesDialogRefetchQuery } from "#/__generated__/core/LinkedThirdPartiesDialogRefetchQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
const query = graphql`
query LinkedThirdPartiesDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...LinkedThirdPartiesDialogFragment
}
}
`;
const thirdPartiesFragment = graphql`
fragment LinkedThirdPartiesDialogFragment on Organization
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
after: { type: "CursorKey" }
last: { type: "Int", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
order: { type: "ThirdPartyOrder", defaultValue: null }
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
)
@refetchable(queryName: "LinkedThirdPartiesDialogRefetchQuery") {
thirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: $filter
) @connection(key: "LinkedThirdPartiesDialogRefetchQuery_thirdParties", filters: ["filter"]) {
edges {
node {
id
name
category
websiteUrl
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedThirdParties?: { id: string }[];
onLink: (thirdPartyId: string) => void;
onUnlink: (thirdPartyId: string) => void;
};
type SearchRef = RefObject<{ search: (v: string) => void } | null>;
export function LinkedThirdPartiesDialog(props: Props) {
const { __ } = useTranslate();
const searchRef: SearchRef = useRef(null);
const contentRef = useRef<HTMLDivElement>(null);
const [minHeight, setMinHeight] = useState(0);
const onSearch = (v: string) => {
setMinHeight(contentRef.current?.clientHeight ?? 0);
searchRef.current?.search(v);
};
return (
<Dialog trigger={props.children} title={__("Link third parties")}>
<DialogContent>
<div className="flex items-center gap-2 sticky top-0 py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search third parties...")}
onValueChange={onSearch}
/>
</div>
<div ref={contentRef}>
<Suspense
fallback={(
<div style={{ minHeight }}>
<Spinner centered />
</div>
)}
>
<LinkedThirdPartiesDialogContent {...props} ref={searchRef} />
</Suspense>
</div>
</DialogContent>
</Dialog>
);
}
function LinkedThirdPartiesDialogContent({
ref: searchRef,
...props
}: Props & { ref: SearchRef }) {
const organizationId = useOrganizationId();
const mainData = useLazyLoadQuery<LinkedThirdPartiesDialogQuery>(query, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext, refetch } = usePaginationFragment<
LinkedThirdPartiesDialogRefetchQuery,
LinkedThirdPartiesDialogFragment$key
>(
thirdPartiesFragment,
mainData.organization as LinkedThirdPartiesDialogFragment$key,
);
const thirdParties = data.thirdParties?.edges?.map(edge => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedThirdParties?.map(t => t.id) ?? []);
}, [props.linkedThirdParties]);
const handleSearch = useDebounceCallback((v: string) => {
refetch({
first: 20,
filter: {
firstLevel: true,
query: v,
},
});
}, 500);
useEffect(() => {
searchRef.current = { search: handleSearch };
return () => {
searchRef.current = null;
};
}, [handleSearch, searchRef]);
return (
<div className="divide-y divide-border-low">
{thirdParties.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
linkedIds={linkedIds}
{...props}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
);
}
function ThirdPartyRow(
props: {
thirdParty: NodeOf<LinkedThirdPartiesDialogFragment$data["thirdParties"]>;
linkedIds: Set<string>;
} & Props,
) {
const { __ } = useTranslate();
const isLinked = props.linkedIds.has(props.thirdParty.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
const logo = faviconUrl(props.thirdParty.websiteUrl);
return (
<button
type="button"
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full text-start disabled:cursor-not-allowed disabled:opacity-50"
disabled={props.disabled}
onClick={() => onClick(props.thirdParty.id)}
>
{logo && (
<img src={logo} alt={props.thirdParty.name} className="rounded h-5 w-5" />
)}
{props.thirdParty.name}
<Badge>{props.thirdParty.category}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} />
{" "}
{isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,118 @@
// 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, useParams } from "react-router";
import type { MeasureThirdPartiesPageFragment$key } from "#/__generated__/core/MeasureThirdPartiesPageFragment.graphql";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
import { LinkedThirdPartiesCard } from "../_components/LinkedThirdPartiesCard";
export const thirdPartiesFragment = graphql`
fragment MeasureThirdPartiesPageFragment on Measure {
id
canCreateMeasureThirdPartyMapping: permission(
action: "core:measure:create-third-party-mapping"
)
canDeleteMeasureThirdPartyMapping: permission(
action: "core:measure:delete-third-party-mapping"
)
thirdParties(first: 100) @connection(key: "MeasureThirdPartiesPage_thirdParties") {
__id
edges {
node {
id
...LinkedThirdPartiesCardFragment
}
}
}
}
`;
const attachThirdPartyMutation = graphql`
mutation MeasureThirdPartiesPageAttachMutation(
$input: CreateMeasureThirdPartyMappingInput!
$connections: [ID!]!
) {
createMeasureThirdPartyMapping(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
...LinkedThirdPartiesCardFragment
}
}
}
}
`;
const detachThirdPartyMutation = graphql`
mutation MeasureThirdPartiesPageDetachMutation(
$input: DeleteMeasureThirdPartyMappingInput!
$connections: [ID!]!
) {
deleteMeasureThirdPartyMapping(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
export default function MeasureThirdPartiesPage() {
const { measureId } = useParams<{ measureId: string }>();
if (!measureId) {
throw new Error("Missing :measureId param in route");
}
const { measure } = useOutletContext<{
measure: MeasureThirdPartiesPageFragment$key;
}>();
const data = useFragment(thirdPartiesFragment, measure);
const connectionId = data.thirdParties.__id;
const thirdParties = data.thirdParties?.edges?.map(edge => edge.node) ?? [];
const canLink = data.canCreateMeasureThirdPartyMapping;
const canUnlink = data.canDeleteMeasureThirdPartyMapping;
const readOnly = !canLink && !canUnlink;
const incrementOptions = {
id: data.id,
node: "thirdParties(first:0)",
};
const [detachThirdParty, isDetaching] = useMutationWithIncrement(
detachThirdPartyMutation,
{
...incrementOptions,
value: -1,
},
);
const [attachThirdParty, isAttaching] = useMutationWithIncrement(
attachThirdPartyMutation,
{
...incrementOptions,
value: 1,
},
);
const isLoading = isDetaching || isAttaching;
return (
<LinkedThirdPartiesCard
disabled={isLoading}
thirdParties={thirdParties}
onAttach={attachThirdParty}
onDetach={detachThirdParty}
params={{ measureId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -44,8 +44,11 @@ import {
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
void measuresFragment;
type Props = {
queryRef: PreloadedQuery<ThirdPartyGraphNodeQuery>;
};
@@ -64,6 +67,7 @@ export default function ThirdPartyDetailPage(props: Props) {
complianceReportsFragment,
thirdParty as ThirdPartyComplianceTabFragment$key,
).complianceReports.edges.length;
const measuresCount = thirdParty.measuresInfos?.totalCount ?? 0;
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
@@ -136,6 +140,10 @@ export default function ThirdPartyDetailPage(props: Props) {
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
{__("Third Parties")}
</TabLink>
<TabLink to={`${baseThirdPartyUrl}/measures`}>
{__("Measures")}
{measuresCount > 0 && <TabBadge>{measuresCount}</TabBadge>}
</TabLink>
</Tabs>
<Outlet context={{ thirdParty }} />

View File

@@ -0,0 +1,117 @@
// 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, useParams } from "react-router";
import type { ThirdPartyMeasuresPageFragment$key } from "#/__generated__/core/ThirdPartyMeasuresPageFragment.graphql";
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const measuresFragment = graphql`
fragment ThirdPartyMeasuresPageFragment on ThirdParty {
id
canCreateMeasureThirdPartyMapping: permission(
action: "core:measure:create-third-party-mapping"
)
canDeleteMeasureThirdPartyMapping: permission(
action: "core:measure:delete-third-party-mapping"
)
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
__id
edges {
node {
id
...LinkedMeasuresCardFragment
}
}
}
}
`;
const attachMeasureMutation = graphql`
mutation ThirdPartyMeasuresPageAttachMutation(
$input: CreateMeasureThirdPartyMappingInput!
$connections: [ID!]!
) {
createMeasureThirdPartyMapping(input: $input) {
measureEdge @prependEdge(connections: $connections) {
node {
id
...LinkedMeasuresCardFragment
}
}
}
}
`;
const detachMeasureMutation = graphql`
mutation ThirdPartyMeasuresPageDetachMutation(
$input: DeleteMeasureThirdPartyMappingInput!
$connections: [ID!]!
) {
deleteMeasureThirdPartyMapping(input: $input) {
deletedMeasureId @deleteEdge(connections: $connections)
}
}
`;
export default function ThirdPartyMeasuresPage() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
if (!thirdPartyId) {
throw new Error("Missing :thirdPartyId param in route");
}
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyMeasuresPageFragment$key;
}>();
const data = useFragment(measuresFragment, thirdParty);
const connectionId = data.measures.__id;
const measures = data.measures?.edges?.map(edge => edge.node) ?? [];
const canLink = data.canCreateMeasureThirdPartyMapping;
const canUnlink = data.canDeleteMeasureThirdPartyMapping;
const readOnly = !canLink && !canUnlink;
const incrementOptions = {
id: data.id,
node: "measures(first:0)",
};
const [detachMeasure, isDetaching] = useMutationWithIncrement(
detachMeasureMutation,
{
...incrementOptions,
value: -1,
},
);
const [attachMeasure, isAttaching] = useMutationWithIncrement(
attachMeasureMutation,
{
...incrementOptions,
value: 1,
},
);
const isLoading = isDetaching || isAttaching;
return (
<LinkedMeasuresCard
disabled={isLoading}
measures={measures}
onAttach={attachMeasure}
onDetach={detachMeasure}
params={{ thirdPartyId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -97,6 +97,14 @@ export const measureRoutes = [
import("#/pages/organizations/measures/tabs/MeasureDocumentsTab"),
),
},
{
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/measures/third-parties/MeasureThirdPartiesPage"),
),
},
],
},
] satisfies AppRoute[];

View File

@@ -105,6 +105,13 @@ export const thirdPartyRoutes = [
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
),
},
{
path: "measures",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/third-parties/measures/ThirdPartyMeasuresPage"),
),
},
],
},
] satisfies AppRoute[];