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

@@ -6,21 +6,23 @@
"scripts": {
"dev": "vite",
"relay": "npx relay-compiler",
"build": "tsc -b && vite build",
"build": "vite build",
"lint": "eslint .",
"check": "tsc --noEmit -p tsconfig.app.json",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@probo/hooks": "1.0.0",
"@probo/helpers": "1.0.0",
"@probo/hooks": "1.0.0",
"@probo/i18n": "1.0.0",
"@probo/ui": "1.0.0",
"@probo/vendors": "0.0.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@tanstack/react-query": "^5.76.1",
"minisearch": "^7.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",

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")
),
},
],

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { isEmpty } from "./array";
describe("array", () => {
it("should check if an array is empty", () => {
expect(isEmpty([])).toBe(true);
expect(isEmpty([1, 2, 3])).toBe(false);
expect(isEmpty(null)).toBe(true);
expect(isEmpty(undefined)).toBe(true);
expect(isEmpty([[], false])).toBe(true);
});
});

View File

@@ -18,3 +18,13 @@ export function groupBy<T>(
{} as Record<string, T[]>,
);
}
/**
* Check that a value is empty (null, undefined, empty string, empty array, empty object)
*/
export function isEmpty(v: unknown) {
if (Array.isArray(v)) {
return v.find((v) => !isEmpty(v)) === undefined;
}
return !v;
}

View File

@@ -1,10 +1,10 @@
export { objectKeys } from "./object";
export { sprintf } from "./string";
export { sprintf, faviconUrl } from "./string";
export {
getTreatment,
getRiskImpacts,
getRiskLikelihoods,
getSeverity,
} from "./risk";
export { times, groupBy } from "./array";
export { times, groupBy, isEmpty } from "./array";
export { randomInt } from "./number";

View File

@@ -7,3 +7,16 @@ export function sprintf(str: string, ...params: unknown[]): string {
return params[i++] as string;
});
}
export function faviconUrl(url?: string | null): string | null {
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
return `https://www.google.com/s2/favicons?domain=${parsedUrl.hostname}&sz=64`;
} catch (e) {
return null;
}
}

View File

@@ -53,5 +53,33 @@ export function useTranslate() {
return {
lang,
__: translate,
dateFormat: (
date: Date | string | null | undefined,
options?: Intl.DateTimeFormatOptions
) => {
if (!date) {
return "";
}
if (typeof date === "string") {
return new Intl.DateTimeFormat(lang, options).format(parseDate(date));
}
return new Intl.DateTimeFormat(lang, options).format(date);
},
};
}
function parseDate(date: Date | string): Date {
if (typeof date === "string") {
if (date.includes("T")) {
return new Date(date);
}
const parts = date.split("-");
return new Date(
parseInt(parts[0], 10),
parts[1] ? parseInt(parts[1], 10) - 1 : 0,
parts[2] ? parseInt(parts[2], 10) : 1
);
}
return date;
}

View File

@@ -17,7 +17,9 @@ const preview: Preview = {
(Story, { parameters }) => {
return (
<BrowserRouter>
<Story />
<div className="text-txt-primary">
<Story />
</div>
</BrowserRouter>
);
},

View File

@@ -12,6 +12,7 @@
},
"prettier": "@probo/prettier",
"dependencies": {
"@ariakit/react": "^0.4.17",
"@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0",
"@radix-ui/react-dropdown-menu": "^2.1.14",

View File

@@ -31,7 +31,7 @@ const badge = tv({
},
},
defaultVariants: {
size: "md",
size: "sm",
},
});

View File

@@ -8,7 +8,7 @@ import { Link } from "react-router";
import { tv, type VariantProps } from "tailwind-variants";
const button = tv({
base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8",
base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8 focus:outline-none",
variants: {
variant: {
primary:

View File

@@ -7,21 +7,24 @@ import { IconDotGrid1x3Horizontal } from "../Icons/IconDotGrid1x3Horizontal.tsx"
import { Button } from "../Button/Button.tsx";
type Props = PropsWithChildren<{
toggle: ReactNode;
toggle?: ReactNode;
className?: string;
open?: boolean;
}>;
export function Dropdown({ children, toggle, className }: Props) {
export const dropdown = tv({
base: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin] z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
});
export function Dropdown({ children, toggle, className, open }: Props) {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{toggle}</DropdownMenu.Trigger>
<DropdownMenu.Root open={open}>
{toggle && (
<DropdownMenu.Trigger asChild>{toggle}</DropdownMenu.Trigger>
)}
<DropdownMenu.Portal>
<DropdownMenu.Content
className={clsx(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
"z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
className,
)}
className={dropdown({ className })}
sideOffset={5}
>
{children}
@@ -47,8 +50,8 @@ type DropdownItemProps = PropsWithChildren<{
}> &
ComponentProps<typeof DropdownMenu.Item>;
const dropdownItem = tv({
base: "text-txt-primary flex items-center gap-2 hover:bg-tertiary-hover active:bg-tertiary-pressed cursor-pointer p-2",
export const dropdownItem = tv({
base: "text-txt-primary flex items-center gap-2 hover:bg-tertiary-hover active:bg-tertiary-pressed data-active-item:bg-tertiary-pressed cursor-pointer p-2",
variants: {
variant: {
primary: "text-txt-primary",

View File

@@ -7,7 +7,7 @@ type Props = {
} & InputHTMLAttributes<HTMLInputElement>;
export const input = tv({
base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3 w-full bg-secondary disabled:bg-transparent",
base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3 w-full bg-secondary disabled:bg-transparent focus:outline-none",
variants: {
invalid: {
true: "border-border-danger",

View File

@@ -85,9 +85,6 @@ const findSelectedOption = (
children: unknown[],
condition: (c: { props: Record<string, unknown> }) => boolean,
): ReactNode => {
if (children.length === 1) {
debugger;
}
const selectedOptions = children.find(
// @ts-expect-error We know that the children are ReactElements with props
(c) => isValidElement(c) && condition(c),

View File

@@ -0,0 +1,12 @@
import { Spinner } from "./Spinner";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Spinner",
component: Spinner,
argTypes: {},
} satisfies Meta<typeof Spinner>;
type Story = StoryObj<typeof Spinner>;
export const Default: Story = {};

View File

@@ -0,0 +1,12 @@
import clsx from "clsx";
type Props = { size: number; className?: string };
export function Spinner({ size = 16, className }: Props) {
return (
<div
className={clsx("animate-spin rounded-full border-b-2", className)}
style={{ width: size, height: size, borderColor: "currentColor" }}
/>
);
}

View File

@@ -12,9 +12,9 @@ type Story = StoryObj<typeof RiskBadge>;
export const Default: Story = {
render: () => (
<div className="space-y-2">
<RiskBadge score={10} />
<RiskBadge score={8} />
<RiskBadge score={0} />
<RiskBadge level={10} />
<RiskBadge level={8} />
<RiskBadge level={0} />
</div>
),
};

View File

@@ -2,29 +2,59 @@ import { useTranslate } from "@probo/i18n";
import { Badge } from "../../Atoms/Badge/Badge";
type Props = {
score: number;
level: number | string;
};
const badgeVariant = (score: number) => {
if (score >= 15) {
return "danger";
const badgeVariant = (level: string | number) => {
if (typeof level === "number") {
if (level >= 15) {
return "CRITICAL";
}
if (level >= 8) {
return "HIGH";
}
return "LOW";
}
if (score >= 8) {
return "warning";
switch (level) {
case "CRITICAL":
return "danger";
case "HIGH":
return "warning";
case "LOW":
return "success";
case "MEDIUM":
return "info";
default:
return "neutral";
}
return "success";
};
export function RiskBadge({ score }: Props) {
export function RiskBadge({ level }: Props) {
const { __ } = useTranslate();
const label = () => {
if (score >= 15) {
return __("High");
if (typeof level === "number") {
if (level >= 15) {
return __("High");
}
if (level >= 8) {
return __("Medium");
}
return __("Low");
}
if (score >= 8) {
return __("Medium");
switch (level) {
case "CRITICAL":
return __("Critical");
case "HIGH":
return __("High");
case "LOW":
return __("Low");
case "MEDIUM":
return __("Medium");
case "NONE":
return __("None");
default:
return __("Low");
}
return __("Low");
};
return <Badge variant={badgeVariant(score)}>{label()}</Badge>;
return <Badge variant={badgeVariant(level)}>{label()}</Badge>;
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { Combobox, ComboboxItem } from "./Combobox";
import type { Meta, StoryObj } from "@storybook/react";
import { times } from "@probo/helpers";
export default {
title: "Atoms/Combobox",
component: Combobox,
argTypes: {},
} satisfies Meta<typeof Combobox>;
type Story = StoryObj<typeof Combobox>;
export const Default: Story = {
args: {
open: true,
},
render: () => {
const [items, setItems] = useState(["a", "b", "c"] as string[]);
const onSearch = (query: string) => {
setItems(times(10, (i) => `${query} ${i}`));
};
return (
<>
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
Nostrum labore repellat facere voluptatum, in voluptatem eaque
quidem nam quod nesciunt repudiandae a illo non placeat nulla.
Ratione ipsa at sint?
<Combobox onSearch={onSearch}>
{items.map((item) => (
<ComboboxItem key={item}>{item}</ComboboxItem>
))}
</Combobox>
</>
);
},
};

View File

@@ -0,0 +1,46 @@
import {
Combobox as AriaKitCombobox,
ComboboxItem as AriaKitComboboxItem,
ComboboxPopover,
ComboboxProvider,
} from "@ariakit/react";
import {
type ComponentProps,
type PropsWithChildren,
type ReactNode,
} from "react";
import { input } from "../../Atoms/Input/Input";
import { dropdown, dropdownItem } from "../../Atoms/Dropdown/Dropdown";
import { isEmpty } from "@probo/helpers";
type Props = {
children: ReactNode;
loading?: boolean;
onSearch: (query: string) => void;
placeholder?: string;
};
export function Combobox({ children, onSearch, placeholder }: Props) {
const showDropdown = !isEmpty(children);
return (
<ComboboxProvider setValue={onSearch}>
<AriaKitCombobox placeholder={placeholder} className={input()} />
{showDropdown && (
<ComboboxPopover gutter={4} sameWidth className={dropdown()}>
{children}
</ComboboxPopover>
)}
</ComboboxProvider>
);
}
export function ComboboxItem({
children,
...props
}: PropsWithChildren<ComponentProps<typeof AriaKitComboboxItem>>) {
return (
<AriaKitComboboxItem className={dropdownItem()} {...props}>
{children}
</AriaKitComboboxItem>
);
}

View File

@@ -48,7 +48,12 @@ export function ConfirmDialog(props: Props) {
<>
{children}
<Portal>
<Dialog open={open} onOpenChange={setOpen} title={title}>
<Dialog
open={open}
onOpenChange={setOpen}
title={title}
className="max-w-[500px]"
>
<DialogContent>
<p className="text-sm text-txt-secondary p-4">
{props.message}

View File

@@ -19,6 +19,7 @@ type Props = {
children?: ReactNode;
onOpenChange?: (open: boolean) => void;
open?: boolean;
className?: string;
};
export function Dialog({
@@ -27,6 +28,7 @@ export function Dialog({
children,
onOpenChange,
open,
className,
}: Props) {
return (
<Root open={open} onOpenChange={onOpenChange}>
@@ -42,6 +44,7 @@ export function Dialog({
aria-describedby={undefined}
className={clsx(
"fixed inset-0 m-auto z-50 w-full h-max bg-level-2 rounded-2xl max-w-5xl w-[95%]",
className,
`duration-200
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
@@ -53,7 +56,11 @@ export function Dialog({
<div className="flex justify-between items-center p-3 border-b border-b-border-low">
<Title className="text-sm font-medium">{title}</Title>
<Close asChild>
<Button variant="tertiary" icon={IconCrossLargeX} />
<Button
tabIndex={-1}
variant="tertiary"
icon={IconCrossLargeX}
/>
</Close>
</div>
{children}

View File

@@ -15,6 +15,7 @@ export { Button } from "./Atoms/Button/Button";
export { Card } from "./Atoms/Card/Card";
export { Breadcrumb } from "./Atoms/Breadcrumb/Breadcrumb";
export { Badge } from "./Atoms/Badge/Badge";
export { Spinner } from "./Atoms/Spinner/Spinner";
export {
Dropdown,
ActionDropdown,
@@ -43,6 +44,7 @@ export { SeverityBadge } from "./Molecules/Badge/SeverityBadge.tsx";
export { ConfirmDialog } from "./Molecules/Dialog/ConfirmDialog.tsx";
export { RisksChart } from "./Molecules/Risks/RisksChart";
export { RiskOverview } from "./Molecules/Risks/RiskOverview";
export { Combobox, ComboboxItem } from "./Molecules/Combobox/Combobox";
// Hooks
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";