Replace vendor JSON with common third parties API

The CreateVendorDialog previously loaded the entire @probo/vendors
JSON bundle client-side and used MiniSearch for fuzzy search. This
replaces it with a GraphQL query against the common_third_parties
database table, searched server-side via ILIKE filtering.

Backend: adds CommonThirdParty GraphQL type, a pkg/thirdparty
service, and a commonThirdParties(name) root query. Frontend:
splits into CommonThirdPartyCombobox (display) and an @inline
fragment read on selection via readInlineData.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-08 19:08:58 +04:00
parent caba84fd74
commit 7099a3d702
15 changed files with 388 additions and 87 deletions

View File

@@ -1,47 +0,0 @@
// 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 type { Vendor } from "@probo/vendors";
import MiniSearch from "minisearch";
import { useEffect, useRef, useState } from "react";
export function useVendorSearch() {
const searchRef = useRef<(s: string) => Vendor[]>(() => []);
const [query, setQuery] = useState("");
const [vendors, setVendors] = useState<Vendor[]>([]);
useEffect(() => {
void 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

@@ -0,0 +1,66 @@
// Copyright (c) 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 { Avatar, ComboboxItem } from "@probo/ui";
import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay";
import type {
CommonThirdPartyComboboxQuery$data,
CommonThirdPartyComboboxQuery,
} from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
export type CommonThirdPartyRef
= CommonThirdPartyComboboxQuery$data["commonThirdParties"][number];
export const commonThirdPartiesQuery = graphql`
query CommonThirdPartyComboboxQuery($name: String!) {
commonThirdParties(name: $name) {
id
name
websiteUrl
...CreateVendorDialog_commonThirdParty
}
}
`;
interface CommonThirdPartyComboboxProps {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
onSelect: (thirdPartyRef: CommonThirdPartyRef) => void;
}
export function CommonThirdPartyCombobox({
queryRef,
onSelect,
}: CommonThirdPartyComboboxProps) {
const data = usePreloadedQuery(commonThirdPartiesQuery, queryRef);
return (
<>
{data.commonThirdParties.map(thirdParty => (
<ComboboxItem
key={thirdParty.id}
onClick={() => onSelect(thirdParty)}
>
<Avatar
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
/>
{thirdParty.name}
</ComboboxItem>
))}
</>
);
}

View File

@@ -12,10 +12,8 @@
// 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 {
Avatar,
Combobox,
ComboboxItem,
Dialog,
@@ -24,11 +22,38 @@ import {
IconPlusLarge,
useDialogRef,
} from "@probo/ui";
import type { Vendor } from "@probo/vendors";
import { type ReactNode } from "react";
import { type ReactNode, Suspense, useState } from "react";
import { useQueryLoader } from "react-relay";
import { graphql, readInlineData } from "relay-runtime";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateVendorDialog_commonThirdParty$key } from "#/__generated__/core/CreateVendorDialog_commonThirdParty.graphql";
import { useCreateVendorMutation } from "#/hooks/graph/VendorGraph";
import { useVendorSearch } from "#/hooks/useVendorSearch";
import {
commonThirdPartiesQuery,
CommonThirdPartyCombobox,
type CommonThirdPartyRef,
} from "./CommonThirdPartyCombobox";
const commonThirdPartyFragment = graphql`
fragment CreateVendorDialog_commonThirdParty on CommonThirdParty @inline {
name
description
category
websiteUrl
headquarterAddress
legalName
privacyPolicyUrl
serviceLevelAgreementUrl
dataProcessingAgreementUrl
certifications
securityPageUrl
trustPageUrl
statusPageUrl
termsOfServiceUrl
}
`;
type Props = {
children: ReactNode;
@@ -42,35 +67,45 @@ export function CreateVendorDialog({
connection,
}: Props) {
const { __ } = useTranslate();
const { search, vendors, query } = useVendorSearch();
const [createVendor] = useCreateVendorMutation();
const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery]
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const onSelect = async (vendor: Vendor | string) => {
const onSelect = async (thirdPartyRef: CommonThirdPartyRef | string) => {
const input
= typeof vendor === "string"
= typeof thirdPartyRef === "string"
? {
organizationId,
name: vendor,
name: thirdPartyRef,
category: null,
}
: {
organizationId,
name: vendor.name,
description: vendor.description || null,
headquarterAddress: vendor.headquarterAddress || null,
legalName: vendor.legalName || null,
websiteUrl: vendor.websiteUrl || null,
category: vendor.category || null,
privacyPolicyUrl: vendor.privacyPolicyUrl || null,
serviceLevelAgreementUrl: vendor.serviceLevelAgreementUrl || null,
dataProcessingAgreementUrl: vendor.dataProcessingAgreementUrl || null,
certifications: vendor.certifications,
countries: vendor.countries,
securityPageUrl: vendor.securityPageUrl || null,
trustPageUrl: vendor.trustPageUrl || null,
statusPageUrl: vendor.statusPageUrl || null,
termsOfServiceUrl: vendor.termsOfServiceUrl || null,
};
: (() => {
const tp = readInlineData<CreateVendorDialog_commonThirdParty$key>(
commonThirdPartyFragment,
thirdPartyRef,
);
return {
organizationId,
name: tp.name,
description: tp.description || null,
headquarterAddress: tp.headquarterAddress || null,
legalName: tp.legalName || null,
websiteUrl: tp.websiteUrl || null,
category: tp.category || null,
privacyPolicyUrl: tp.privacyPolicyUrl || null,
serviceLevelAgreementUrl:
tp.serviceLevelAgreementUrl || null,
dataProcessingAgreementUrl:
tp.dataProcessingAgreementUrl || null,
certifications: tp.certifications,
securityPageUrl: tp.securityPageUrl || null,
trustPageUrl: tp.trustPageUrl || null,
statusPageUrl: tp.statusPageUrl || null,
termsOfServiceUrl: tp.termsOfServiceUrl || null,
};
})();
await createVendor({
variables: {
input,
@@ -82,25 +117,32 @@ export function CreateVendorDialog({
});
};
const dialogRef = useDialogRef();
const handleSearch = (name: string) => {
setSearchQuery(name);
if (name.trim().length >= 2) {
loadQuery({ name: name.trim() });
}
};
return (
<Dialog ref={dialogRef} trigger={children} title={__("Add a vendor")}>
<DialogContent className="p-6">
<Combobox onSearch={search} placeholder={__("Type vendor's name")}>
{vendors.map(vendor => (
<ComboboxItem key={vendor.name} onClick={() => void onSelect(vendor)}>
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
{vendor.name}
</ComboboxItem>
))}
{query.trim().length >= 2 && (
<ComboboxItem onClick={() => void onSelect(query.trim())}>
<Combobox onSearch={handleSearch} placeholder={__("Type vendor's name")}>
{queryRef && (
<Suspense>
<CommonThirdPartyCombobox
queryRef={queryRef}
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
/>
</Suspense>
)}
{searchQuery.trim().length >= 2 && (
<ComboboxItem onClick={() => void onSelect(searchQuery.trim())}>
<IconPlusLarge size={20} />
{__("Create a new vendor")}
{" "}
:
{query}
{searchQuery}
</ComboboxItem>
)}
</Combobox>