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:
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
66
apps/console/src/pages/organizations/vendors/dialogs/CommonThirdPartyCombobox.tsx
vendored
Normal file
66
apps/console/src/pages/organizations/vendors/dialogs/CommonThirdPartyCombobox.tsx
vendored
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user