Add vendor creation

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-22 21:29:42 +02:00
committed by Sacha Al Himdani
parent fe3128da2a
commit c2a69b5428
38 changed files with 1426 additions and 103 deletions

View File

@@ -71,6 +71,7 @@ function PeopleSelectWithQuery({
id={name}
variant="editor"
placeholder={__("Select an owner")}
onValueChange={field.onChange}
{...field}
>
{people?.map((p) => (

View File

@@ -1,10 +1,13 @@
import { graphql } from "relay-runtime";
import { useTranslate } from "@probo/i18n";
import { useMutationWithToasts } from "../hooks/useMutationWithToasts";
import type { RisksDeleteMutation } from "./__generated__/RisksDeleteMutation.graphql";
import type { RiskGraphDeleteMutation } from "./__generated__/RiskGraphDeleteMutation.graphql";
const deleteRiskMutation = graphql`
mutation RisksDeleteMutation($input: DeleteRiskInput!, $connections: [ID!]!) {
mutation RiskGraphDeleteMutation(
$input: DeleteRiskInput!
$connections: [ID!]!
) {
deleteRisk(input: $input) {
deletedRiskId @deleteEdge(connections: $connections)
}
@@ -14,7 +17,7 @@ const deleteRiskMutation = graphql`
export function useDeleteRiskMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<RisksDeleteMutation>(deleteRiskMutation, {
return useMutationWithToasts<RiskGraphDeleteMutation>(deleteRiskMutation, {
successMessage: __("Risk deleted successfully."),
errorMessage: __("Failed to delete risk. Please try again."),
});

View File

@@ -0,0 +1,60 @@
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../hooks/useMutationWithToasts";
import type { VendorGraphCreateMutation } from "./__generated__/VendorGraphCreateMutation.graphql";
import type { VendorGraphDeleteMutation } from "./__generated__/VendorGraphDeleteMutation.graphql";
const createVendorMutation = graphql`
mutation VendorGraphCreateMutation(
$input: CreateVendorInput!
$connections: [ID!]!
) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
description
websiteUrl
createdAt
updatedAt
}
}
}
}
`;
export function useCreateVendorMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<VendorGraphCreateMutation>(
createVendorMutation,
{
successMessage: __("Vendor created successfully."),
errorMessage: __("Failed to create vendor. Please try again."),
}
);
}
const deleteVendorMutation = graphql`
mutation VendorGraphDeleteMutation(
$input: DeleteVendorInput!
$connections: [ID!]!
) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
export function useDeleteVendorMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<VendorGraphDeleteMutation>(
deleteVendorMutation,
{
successMessage: __("Vendor deleted successfully."),
errorMessage: __("Failed to delete vendor. Please try again."),
}
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<5102c0f9ea660855dc5db7c6b16fd64b>>
* @generated SignedSource<<dec71a6edcf899669b0b8e29a8b77aad>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
export type DeleteRiskInput = {
riskId: string;
};
export type RisksDeleteMutation$variables = {
export type RiskGraphDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteRiskInput;
};
export type RisksDeleteMutation$data = {
export type RiskGraphDeleteMutation$data = {
readonly deleteRisk: {
readonly deletedRiskId: string;
};
};
export type RisksDeleteMutation = {
response: RisksDeleteMutation$data;
variables: RisksDeleteMutation$variables;
export type RiskGraphDeleteMutation = {
response: RiskGraphDeleteMutation$data;
variables: RiskGraphDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -59,7 +59,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "RisksDeleteMutation",
"name": "RiskGraphDeleteMutation",
"selections": [
{
"alias": null,
@@ -84,7 +84,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "RisksDeleteMutation",
"name": "RiskGraphDeleteMutation",
"selections": [
{
"alias": null,
@@ -117,16 +117,16 @@ return {
]
},
"params": {
"cacheID": "f4273ec8d2cd76543169a6d1b7cd72b3",
"cacheID": "57b2b12cd623a9c0a99bc5d13284e21e",
"id": null,
"metadata": {},
"name": "RisksDeleteMutation",
"name": "RiskGraphDeleteMutation",
"operationKind": "mutation",
"text": "mutation RisksDeleteMutation(\n $input: DeleteRiskInput!\n) {\n deleteRisk(input: $input) {\n deletedRiskId\n }\n}\n"
"text": "mutation RiskGraphDeleteMutation(\n $input: DeleteRiskInput!\n) {\n deleteRisk(input: $input) {\n deletedRiskId\n }\n}\n"
}
};
})();
(node as any).hash = "ad609ad0f9c749fe47f2bd0d382777bb";
(node as any).hash = "a930aa55cb8dcca220dfb26783550877";
export default node;

View File

@@ -0,0 +1,214 @@
/**
* @generated SignedSource<<37dd4db659813ffa6fab65ffec643844>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateVendorInput = {
businessOwnerId?: string | null | undefined;
category?: string | null | undefined;
certifications?: ReadonlyArray<string> | null | undefined;
dataProcessingAgreementUrl?: string | null | undefined;
description?: string | null | undefined;
headquarterAddress?: string | null | undefined;
legalName?: string | null | undefined;
name: string;
organizationId: string;
privacyPolicyUrl?: string | null | undefined;
securityOwnerId?: string | null | undefined;
securityPageUrl?: string | null | undefined;
serviceLevelAgreementUrl?: string | null | undefined;
statusPageUrl?: string | null | undefined;
termsOfServiceUrl?: string | null | undefined;
trustPageUrl?: string | null | undefined;
websiteUrl?: string | null | undefined;
};
export type VendorGraphCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateVendorInput;
};
export type VendorGraphCreateMutation$data = {
readonly createVendor: {
readonly vendorEdge: {
readonly node: {
readonly createdAt: any;
readonly description: string | null | undefined;
readonly id: string;
readonly name: string;
readonly updatedAt: any;
readonly websiteUrl: string | null | undefined;
};
};
};
};
export type VendorGraphCreateMutation = {
response: VendorGraphCreateMutation$data;
variables: VendorGraphCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "VendorEdge",
"kind": "LinkedField",
"name": "vendorEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "VendorGraphCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateVendorPayload",
"kind": "LinkedField",
"name": "createVendor",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "VendorGraphCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateVendorPayload",
"kind": "LinkedField",
"name": "createVendor",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "vendorEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "b6e686d8c3e4add7f11f09fd0d1e1de3",
"id": null,
"metadata": {},
"name": "VendorGraphCreateMutation",
"operationKind": "mutation",
"text": "mutation VendorGraphCreateMutation(\n $input: CreateVendorInput!\n) {\n createVendor(input: $input) {\n vendorEdge {\n node {\n id\n name\n description\n websiteUrl\n createdAt\n updatedAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "ae71994587599f61caf6b79d9159ad44";
export default node;

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<6b924850fd9bad48c4fe32c331a7770b>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteVendorInput = {
vendorId: string;
};
export type VendorGraphDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteVendorInput;
};
export type VendorGraphDeleteMutation$data = {
readonly deleteVendor: {
readonly deletedVendorId: string;
};
};
export type VendorGraphDeleteMutation = {
response: VendorGraphDeleteMutation$data;
variables: VendorGraphDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedVendorId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "VendorGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteVendorPayload",
"kind": "LinkedField",
"name": "deleteVendor",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "VendorGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteVendorPayload",
"kind": "LinkedField",
"name": "deleteVendor",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedVendorId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "866254027729839312bc72c942de3111",
"id": null,
"metadata": {},
"name": "VendorGraphDeleteMutation",
"operationKind": "mutation",
"text": "mutation VendorGraphDeleteMutation(\n $input: DeleteVendorInput!\n) {\n deleteVendor(input: $input) {\n deletedVendorId\n }\n}\n"
}
};
})();
(node as any).hash = "82648f6825e035f9abe617d5f641d537";
export default node;

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch";
import type { Vendor } from "@probo/vendors";
export function useVendorSearch() {
const searchRef = useRef<(s: string) => Vendor[]>(() => []);
const [query, setQuery] = useState("");
const [vendors, setVendors] = useState<Vendor[]>([]);
useEffect(() => {
import("@probo/vendors").then((module) => {
const ms = new MiniSearch({
fields: ["name"],
storeFields: Object.keys(module.default[0]),
searchOptions: {
fuzzy: 0.1,
prefix: true,
},
});
ms.addAll(module.default.map((v) => ({ ...v, id: v.name })));
// @ts-expect-error not enough types to handle this case
searchRef.current = ms.search.bind(ms);
});
}, []);
return {
query,
vendors: vendors.slice(0, 20),
search: (s: string) => {
setQuery(s);
setVendors(searchRef.current(s));
},
};
}

View File

@@ -36,6 +36,7 @@ type Props = {
open?: boolean;
risk?: RiskKey;
onSuccess?: () => void;
connection: string;
};
type RiskTemplate = {
@@ -77,6 +78,7 @@ export default function FormRiskDialog({
risk,
onSuccess,
open,
connection,
}: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
@@ -113,17 +115,13 @@ export default function FormRiskDialog({
});
return;
}
const connectionID = ConnectionHandler.getConnectionID(
organizationId,
"RisksPage_risks"
);
createRisk({
variables: {
input: {
...data,
organizationId,
},
connections: [connectionID],
connections: [connection],
},
successMessage: __("Risk created successfully."),
errorMessage: __("Failed to create risk. Please try again."),
@@ -236,6 +234,7 @@ export default function FormRiskDialog({
/>
{showNote && (
<Textarea
{...register("note")}
className="animate-in slide-in-from-top-2"
placeholder={__("Add any additional notes about this risk")}
/>
@@ -283,7 +282,7 @@ function ImpactAndLikelihood({
error={errors?.[`${prefix}Impact`]?.message}
>
{getRiskImpacts(__).map((i) => (
<Option key={i.value} value={i.value}>
<Option key={i.value} value={i.value.toString()}>
{i.value} - {i.label}
</Option>
))}
@@ -297,7 +296,7 @@ function ImpactAndLikelihood({
error={errors?.[`${prefix}Likelihood`]?.message}
>
{getRiskLikelihoods(__).map((l) => (
<Option key={l.value} value={l.value}>
<Option key={l.value} value={l.value.toString()}>
{l.value} - {l.label}
</Option>
))}

View File

@@ -25,7 +25,7 @@ import type {
import FormRiskDialog from "./FormRiskDialog";
import { usePageTitle } from "@probo/hooks";
import { useOrganizationId } from "../../../hooks/useOrganizationId";
import { useDeleteRiskMutation } from "../../../mutations/Risks";
import { useDeleteRiskMutation } from "../../../graph/RiskGraph";
const riskQuery = graphql`
query RiskDetailPageQuery($riskId: ID!) {

View File

@@ -19,20 +19,15 @@ import {
import { useTranslate } from "@probo/i18n";
import { IconPlusLarge } from "@probo/ui";
import FormRiskDialog from "./FormRiskDialog";
import { ConnectionHandler, graphql } from "relay-runtime";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type {
RisksPageQuery as RisksPageQueryType,
RiskTreatment,
} from "./__generated__/RisksPageQuery.graphql";
import type { RisksPageQuery as RisksPageQueryType } from "./__generated__/RisksPageQuery.graphql";
import { useOrganizationId } from "../../../hooks/useOrganizationId";
import { useMemo, useState } from "react";
import { useState } from "react";
import { usePageTitle } from "@probo/hooks";
import type { RisksPageDeleteMutation } from "./__generated__/RisksPageDeleteMutation.graphql";
import { useMutationWithToasts } from "../../../hooks/useMutationWithToasts";
import { getTreatment, sprintf } from "@probo/helpers";
import type { ItemOf } from "../../../types";
import { useDeleteRiskMutation } from "../../../mutations/Risks";
import { useDeleteRiskMutation } from "../../../graph/RiskGraph";
const risksQuery = graphql`
query RisksPageQuery(
@@ -46,6 +41,7 @@ const risksQuery = graphql`
... on Organization {
risks(first: $first, after: $after, last: $last, before: $before)
@connection(key: "RisksPage_risks") {
__id
edges {
node {
id
@@ -78,16 +74,14 @@ export default function RisksPage() {
const organizationId = useOrganizationId();
const [deleteRisk] = useDeleteRiskMutation();
const connectionId = data.organization!.risks!.__id;
const onDelete = (riskId: string) => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
"RisksPage_risks"
);
risks;
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
connections: [],
},
});
};
@@ -98,6 +92,7 @@ export default function RisksPage() {
<div className="space-y-6">
<PageHeader title={__("Risks")}>
<FormRiskDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
</PageHeader>
@@ -105,6 +100,7 @@ export default function RisksPage() {
<FormRiskDialog
open
risk={editedRisk}
connection={connectionId}
onSuccess={() => setEditedRisk(null)}
/>
)}
@@ -144,12 +140,12 @@ export default function RisksPage() {
<Td>{getTreatment(__, risk.treatment)}</Td>
<Td>
<RiskBadge
score={risk.inherentLikelihood * risk.inherentImpact}
level={risk.inherentLikelihood * risk.inherentImpact}
/>
</Td>
<Td>
<RiskBadge
score={risk.residualLikelihood * risk.residualImpact}
level={risk.residualLikelihood * risk.residualImpact}
/>
</Td>
<Td>

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<5ce40300f73da895316795164a9bcf88>>
* @generated SignedSource<<8bfe33d4131ba478fc7cb040fac9197c>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -21,6 +21,7 @@ export type RisksPageQuery$variables = {
export type RisksPageQuery$data = {
readonly organization: {
readonly risks?: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
@@ -192,7 +193,19 @@ v17 = {
],
"storageKey": null
},
v18 = [
v18 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
},
v19 = [
{
"kind": "Variable",
"name": "after",
@@ -284,7 +297,8 @@ return {
],
"storageKey": null
},
(v17/*: any*/)
(v17/*: any*/),
(v18/*: any*/)
],
"storageKey": null
}
@@ -325,7 +339,7 @@ return {
"selections": [
{
"alias": null,
"args": (v18/*: any*/),
"args": (v19/*: any*/),
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "risks",
@@ -390,13 +404,14 @@ return {
],
"storageKey": null
},
(v17/*: any*/)
(v17/*: any*/),
(v18/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": (v18/*: any*/),
"args": (v19/*: any*/),
"filters": null,
"handle": "connection",
"key": "RisksPage_risks",
@@ -436,6 +451,6 @@ return {
};
})();
(node as any).hash = "bf112f97f377b95f3b83ec949e5d5e2f";
(node as any).hash = "7f07b7d980ca4566fbd96b734cb0f04e";
export default node;

View File

@@ -45,7 +45,7 @@ export const riskSchema = z.object({
inherentImpact: z.number({ coerce: true }).min(1).max(5),
residualLikelihood: z.number({ coerce: true }).min(1).max(5),
residualImpact: z.number({ coerce: true }).min(1).max(5),
note: z.string(),
note: z.string().optional(),
});
export const useRiskForm = (riskKey?: RiskKey) => {
@@ -56,19 +56,7 @@ export const useRiskForm = (riskKey?: RiskKey) => {
...risk,
ownerId: risk.owner?.id,
}
: {
category: "Compliance & Legal",
name: "Contractual risk due to poorly drafted agreements",
description:
"Weak contracts leads to disputes, missed deliverables, or revenue leakage",
ownerId: "a_YJLJ5RAAIACAAAAZbuSmzPhFPhoMEQ",
treatment: "MITIGATED",
inherentLikelihood: 5,
inherentImpact: 5,
residualLikelihood: 5,
residualImpact: 5,
note: "Hello worlds this is a test",
},
: {},
});
};

View File

@@ -0,0 +1,95 @@
import { useTranslate } from "@probo/i18n";
import {
Combobox,
ComboboxItem,
Dialog,
DialogContent,
DialogFooter,
Avatar,
IconPlusLarge,
} from "@probo/ui";
import { useVendorSearch } from "../../../hooks/useVendorSearch";
import { faviconUrl } from "@probo/helpers";
import type { Vendor } from "@probo/vendors";
import { useCreateVendorMutation } from "../../../graph/VendorGraph";
import { useState } from "react";
type Props = {
trigger: React.ReactNode;
organizationId: string;
connection: string;
};
export function CreateVendorDialog({
trigger,
organizationId,
connection,
}: Props) {
const { __ } = useTranslate();
const { search, vendors, query } = useVendorSearch();
const [createVendor] = useCreateVendorMutation();
const [open, setOpen] = useState(false);
const onSelect = (vendor: Vendor | string) => {
const input =
typeof vendor === "string"
? {
organizationId,
name: vendor,
description: "",
}
: {
organizationId,
name: vendor.name,
description: vendor.description,
headquarterAddress: vendor.headquarterAddress,
legalName: vendor.legalName,
websiteUrl: vendor.websiteUrl,
category: vendor.category,
privacyPolicyUrl: vendor.privacyPolicyUrl,
serviceLevelAgreementUrl: vendor.serviceLevelAgreementUrl,
dataProcessingAgreementUrl: vendor.dataProcessingAgreementUrl,
certifications: vendor.certifications,
securityPageUrl: vendor.securityPageUrl,
trustPageUrl: vendor.trustPageUrl,
statusPageUrl: vendor.statusPageUrl,
termsOfServiceUrl: vendor.termsOfServiceUrl,
};
createVendor({
variables: {
input,
connections: [connection],
},
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog
title={__("Add a vendor")}
trigger={trigger}
open={open}
onOpenChange={setOpen}
>
<DialogContent className="p-6">
<Combobox onSearch={search} placeholder={__("Type vendor's name")}>
{vendors.map((vendor) => (
<ComboboxItem key={vendor.name} onClick={() => onSelect(vendor)}>
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
{vendor.name}
</ComboboxItem>
))}
{query.trim().length >= 2 && (
<ComboboxItem onClick={() => onSelect(query.trim())}>
<IconPlusLarge size={20} />
{__("Create a new vendor")} : {query}
</ComboboxItem>
)}
</Combobox>
</DialogContent>
<DialogFooter />
</Dialog>
);
}

View File

@@ -0,0 +1,199 @@
import {
Button,
IconPlusLarge,
PageHeader,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Avatar,
RiskBadge,
Badge,
ActionDropdown,
DropdownItem,
IconTrashCan,
ConfirmDialog,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks";
import { ConnectionHandler, graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import { useOrganizationId } from "../../../hooks/useOrganizationId";
import type {
VendorsPageQuery,
VendorsPageQuery$data,
} from "./__generated__/VendorsPageQuery.graphql";
import { faviconUrl, sprintf } from "@probo/helpers";
import type { NodeOf } from "../../../types";
import { CreateVendorDialog } from "./CreateVendorDialog";
import { useDeleteVendorMutation } from "../../../graph/VendorGraph";
const vendorsQuery = graphql`
query VendorsPageQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
vendors(first: 25) @connection(key: "VendorsPage_vendors") {
__id
edges {
node {
id
name
websiteUrl
updatedAt
riskAssessments(
first: 1
orderBy: { direction: DESC, field: ASSESSED_AT }
) {
edges {
node {
id
assessedAt
expiresAt
dataSensitivity
businessImpact
}
}
}
}
}
}
}
}
}
`;
type Vendor = NodeOf<Required<VendorsPageQuery$data["node"]>["vendors"]>;
export default function VendorsPage() {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const data = useLazyLoadQuery<VendorsPageQuery>(vendorsQuery, {
organizationId,
});
console.log(data);
const vendors = data.node?.vendors?.edges.map((edge) => edge.node);
usePageTitle(__("Vendors"));
return (
<div className="space-y-6">
<PageHeader
title={__("Vendors")}
description={__(
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status."
)}
>
<CreateVendorDialog
connection={data.node!.vendors!.__id}
organizationId={organizationId}
trigger={<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>}
/>
</PageHeader>
<Table>
<Thead>
<Tr>
<Th>{__("Vendor")}</Th>
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
<Th>{__("Compliance status")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{vendors?.map((vendor) => (
<VendorRow
key={vendor.id}
vendor={vendor}
organizationId={organizationId}
/>
))}
</Tbody>
</Table>
</div>
);
}
function VendorRow({
vendor,
organizationId,
}: {
vendor: Vendor;
organizationId: string;
}) {
const { __, dateFormat } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const isExpired = latestAssessment
? new Date(latestAssessment.expiresAt) < new Date()
: false;
const [deleteVendor] = useDeleteVendorMutation();
const onDelete = (vendorId: string) => {
deleteVendor({
variables: {
input: {
vendorId,
},
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"VendorsPage_vendors"
),
],
},
});
};
return (
<Tr to={`/organizations/${organizationId}/vendors/${vendor.id}`}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
<div>{vendor.name}</div>
</div>
</Td>
<Td>
{latestAssessment?.assessedAt
? dateFormat(latestAssessment.assessedAt, {
day: "2-digit",
weekday: "short",
month: "short",
})
: __("Not assessed")}
</Td>
<Td>
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
</Td>
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td>
<Badge variant={isExpired ? "danger" : "warning"}>
{isExpired ? __("Late") : __("In progress")}
</Badge>
</Td>
<Td noLink>
<ActionDropdown>
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the vendor "%s". This action cannot be undone.'
),
vendor.name
)}
onConfirm={() => onDelete(vendor.id)}
>
<DropdownItem variant="danger" icon={IconTrashCan}>
{__("Delete")}
</DropdownItem>
</ConfirmDialog>
</ActionDropdown>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,362 @@
/**
* @generated SignedSource<<ddd22d66836f5fd323ddf25475dd6d72>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type BusinessImpact = "CRITICAL" | "HIGH" | "LOW" | "MEDIUM" | "%future added value";
export type DataSensitivity = "CRITICAL" | "HIGH" | "LOW" | "MEDIUM" | "NONE" | "%future added value";
export type VendorsPageQuery$variables = {
organizationId: string;
};
export type VendorsPageQuery$data = {
readonly node: {
readonly vendors?: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly name: string;
readonly riskAssessments: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly assessedAt: any;
readonly businessImpact: BusinessImpact;
readonly dataSensitivity: DataSensitivity;
readonly expiresAt: any;
readonly id: string;
};
}>;
};
readonly updatedAt: any;
readonly websiteUrl: string | null | undefined;
};
}>;
};
};
};
export type VendorsPageQuery = {
response: VendorsPageQuery$data;
variables: VendorsPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"alias": null,
"args": null,
"concreteType": "VendorEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Vendor",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "DESC",
"field": "ASSESSED_AT"
}
}
],
"concreteType": "VendorRiskAssessmentConnection",
"kind": "LinkedField",
"name": "riskAssessments",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "VendorRiskAssessmentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "VendorRiskAssessment",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "assessedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "dataSensitivity",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "businessImpact",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "riskAssessments(first:1,orderBy:{\"direction\":\"DESC\",\"field\":\"ASSESSED_AT\"})"
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 25
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "VendorsPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
{
"alias": "vendors",
"args": null,
"concreteType": "VendorConnection",
"kind": "LinkedField",
"name": "__VendorsPage_vendors_connection",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "VendorsPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "VendorConnection",
"kind": "LinkedField",
"name": "vendors",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": "vendors(first:25)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "VendorsPage_vendors",
"kind": "LinkedHandle",
"name": "vendors"
}
],
"type": "Organization",
"abstractKey": null
},
(v2/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "cfdef0fe4c1b737de9dfbf0a27a3de44",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"node",
"vendors"
]
}
]
},
"name": "VendorsPageQuery",
"operationKind": "query",
"text": "query VendorsPageQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n vendors(first: 25) {\n edges {\n node {\n id\n name\n websiteUrl\n updatedAt\n riskAssessments(first: 1, orderBy: {direction: DESC, field: ASSESSED_AT}) {\n edges {\n node {\n id\n assessedAt\n expiresAt\n dataSensitivity\n businessImpact\n }\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "ce37725ed4c8f58ae2e4af7cf6ee8564";
export default node;

View File

@@ -50,7 +50,7 @@ const routes = [
{
path: "organizations/new",
Component: lazy(
() => import("./pages/organizations/NewOrganizationPage"),
() => import("./pages/organizations/NewOrganizationPage")
),
},
],
@@ -60,6 +60,13 @@ const routes = [
Component: MainLayout,
ErrorBoundary: ErrorBoundary,
children: [
{
path: "vendors",
fallback: PageSkeleton,
Component: lazy(
() => import("./pages/organizations/vendors/VendorsPage")
),
},
{
path: "risks",
fallback: RisksPageSkeleton,
@@ -69,7 +76,7 @@ const routes = [
path: "risks/:riskId",
fallback: PageSkeleton,
Component: lazy(
() => import("./pages/organizations/risks/RiskDetailPage"),
() => import("./pages/organizations/risks/RiskDetailPage")
),
},
],