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