From 627214672c5577549d8a01191f3aa3f80f981621 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:41:23 -0500 Subject: [PATCH 1/3] [dev] [tofikwest] tofik/vendor-multiple-suggestions (#1994) * feat(vendors): enhance vendor creation with name trimming and duplicate check * refactor(vendors): update vendor name selection logic for consistency * fix(vendors): trim whitespace from vendor name input --------- Co-authored-by: Tofik Hasanov --- .../vendors/actions/create-vendor-action.ts | 79 ++- .../VendorNameAutocompleteField.tsx | 219 ++++++++ .../components/create-vendor-form-schema.ts | 18 + .../vendors/components/create-vendor-form.tsx | 480 +++++++----------- 4 files changed, 498 insertions(+), 298 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/components/VendorNameAutocompleteField.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form-schema.ts diff --git a/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts b/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts index b2683ea3d..5ddeac95e 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts +++ b/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts @@ -95,7 +95,10 @@ const triggerRiskAssessmentIfMissing = async (params: { const schema = z.object({ organizationId: z.string().min(1, 'Organization ID is required'), - name: z.string().min(1, 'Name is required'), + name: z + .string() + .trim() + .min(1, 'Name is required'), // Treat empty string as "not provided" so the form default doesn't block submission website: z .union([z.string().url('Must be a valid URL (include https://)'), z.literal('')]) @@ -116,7 +119,10 @@ export const createVendorAction = createSafeActionClient() }); if (!session?.user?.id) { - throw new Error('Unauthorized'); + return { + success: false, + error: 'Unauthorized', + }; } // Security: verify the current user is a member of the target organization. @@ -131,7 +137,29 @@ export const createVendorAction = createSafeActionClient() }); if (!member) { - throw new Error('Unauthorized'); + return { + success: false, + error: 'Unauthorized', + }; + } + + // Check if vendor with same name already exists for this organization + const existingVendor = await db.vendor.findFirst({ + where: { + organizationId: input.parsedInput.organizationId, + name: { + equals: input.parsedInput.name, + mode: 'insensitive', + }, + }, + select: { id: true, name: true }, + }); + + if (existingVendor) { + return { + success: false, + error: `A vendor named "${existingVendor.name}" already exists in this organization.`, + }; } const vendor = await db.vendor.create({ @@ -146,6 +174,51 @@ export const createVendorAction = createSafeActionClient() }, }); + // Create or update GlobalVendors entry immediately so vendor is searchable + // This ensures the vendor appears in global vendor search suggestions right away + const normalizedWebsite = normalizeWebsite(vendor.website ?? null); + if (normalizedWebsite) { + try { + // Check if GlobalVendors entry already exists + const existingGlobalVendor = await db.globalVendors.findUnique({ + where: { website: normalizedWebsite }, + select: { company_description: true }, + }); + + const updateData: { + company_name: string; + company_description?: string | null; + } = { + company_name: vendor.name, + }; + + // Only update description if GlobalVendors doesn't have one yet + if (!existingGlobalVendor?.company_description) { + updateData.company_description = vendor.description || null; + } + + await db.globalVendors.upsert({ + where: { website: normalizedWebsite }, + create: { + website: normalizedWebsite, + company_name: vendor.name, + company_description: vendor.description || null, + approved: false, + }, + update: updateData, + }); + } catch (error) { + // Non-blocking: vendor creation succeeded, GlobalVendors upsert is optional + console.warn('[createVendorAction] Failed to upsert GlobalVendors (non-blocking)', { + organizationId: input.parsedInput.organizationId, + vendorId: vendor.id, + vendorName: vendor.name, + normalizedWebsite, + error: error instanceof Error ? error.message : String(error), + }); + } + } + // If we don't already have GlobalVendors risk assessment data for this website, trigger research. // Best-effort: vendor creation should succeed even if the trigger fails. try { diff --git a/apps/app/src/app/(app)/[orgId]/vendors/components/VendorNameAutocompleteField.tsx b/apps/app/src/app/(app)/[orgId]/vendors/components/VendorNameAutocompleteField.tsx new file mode 100644 index 000000000..f6d8405af --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/components/VendorNameAutocompleteField.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useDebouncedCallback } from '@/hooks/use-debounced-callback'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; +import { Input } from '@comp/ui/input'; +import type { GlobalVendors } from '@db'; +import { useAction } from 'next-safe-action/hooks'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { UseFormReturn } from 'react-hook-form'; +import { searchGlobalVendorsAction } from '../actions/search-global-vendors-action'; +import type { CreateVendorFormValues } from './create-vendor-form-schema'; + +const getVendorDisplayName = (vendor: GlobalVendors): string => { + return vendor.company_name ?? vendor.legal_name ?? vendor.website ?? ''; +}; + +const normalizeVendorName = (name: string): string => { + return name.toLowerCase().trim(); +}; + +const getVendorKey = (vendor: GlobalVendors): string => { + // `website` is the primary key and should always be present. + if (vendor.website) return vendor.website; + + const name = vendor.company_name || vendor.legal_name || 'unknown'; + const timestamp = vendor.createdAt?.getTime() ?? 0; + return `${name}-${timestamp}`; +}; + +type Props = { + form: UseFormReturn; + isSheetOpen: boolean; +}; + +export function VendorNameAutocompleteField({ form, isSheetOpen }: Props) { + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); + + // Used to avoid resetting on initial mount. + const hasOpenedOnceRef = useRef(false); + + const searchVendors = useAction(searchGlobalVendorsAction, { + onExecute: () => setIsSearching(true), + onSuccess: (result) => { + if (result.data?.success && result.data.data?.vendors) { + setSearchResults(result.data.data.vendors); + } else { + setSearchResults([]); + } + setIsSearching(false); + }, + onError: () => { + setSearchResults([]); + setIsSearching(false); + }, + }); + + const debouncedSearch = useDebouncedCallback((query: string) => { + if (query.trim().length > 1) { + searchVendors.execute({ name: query }); + } else { + setSearchResults([]); + } + }, 300); + + // Reset autocomplete state when the sheet closes. + useEffect(() => { + if (isSheetOpen) { + hasOpenedOnceRef.current = true; + return; + } + + if (!hasOpenedOnceRef.current) return; + + setSearchQuery(''); + setSearchResults([]); + setIsSearching(false); + setPopoverOpen(false); + }, [isSheetOpen]); + + const deduplicatedSearchResults = useMemo(() => { + if (searchResults.length === 0) return []; + + const seen = new Map(); + + for (const vendor of searchResults) { + const displayName = getVendorDisplayName(vendor); + const normalizedName = normalizeVendorName(displayName); + const existing = seen.get(normalizedName); + + if (!existing) { + seen.set(normalizedName, vendor); + continue; + } + + // Prefer vendor with more complete data. + const existingHasCompanyName = !!existing.company_name; + const currentHasCompanyName = !!vendor.company_name; + + if (!existingHasCompanyName && currentHasCompanyName) { + seen.set(normalizedName, vendor); + continue; + } + + if (existingHasCompanyName === currentHasCompanyName) { + if (!existing.website && vendor.website) { + seen.set(normalizedName, vendor); + } + } + } + + return Array.from(seen.values()); + }, [searchResults]); + + const handleSelectVendor = (vendor: GlobalVendors) => { + // Use same fallback logic as getVendorDisplayName for consistency + const name = getVendorDisplayName(vendor); + + form.setValue('name', name, { shouldDirty: true, shouldValidate: true }); + form.setValue('website', vendor.website ?? '', { shouldDirty: true, shouldValidate: true }); + form.setValue('description', vendor.company_description ?? '', { + shouldDirty: true, + shouldValidate: true, + }); + + setSearchQuery(name); + setSearchResults([]); + setPopoverOpen(false); + }; + + return ( + ( + + {'Vendor Name'} + +
+ { + const val = e.target.value; + setSearchQuery(val); + field.onChange(val); + debouncedSearch(val); + + if (val.trim().length > 1) { + setPopoverOpen(true); + } else { + setPopoverOpen(false); + setSearchResults([]); + } + }} + onBlur={() => { + setTimeout(() => setPopoverOpen(false), 150); + }} + onFocus={() => { + // Prevent flicker on initial focus: only show if we have results or an active search. + if (searchQuery.trim().length > 1 && (isSearching || searchResults.length > 0)) { + setPopoverOpen(true); + } + }} + autoFocus + /> + + {popoverOpen && ( +
+
+ {isSearching && ( +
Loading...
+ )} + + {!isSearching && deduplicatedSearchResults.length > 0 && ( + <> +

+ {'Suggestions'} +

+ {deduplicatedSearchResults.map((vendor) => ( +
handleSelectVendor(vendor)} + > + {getVendorDisplayName(vendor)} +
+ ))} + + )} + + {!isSearching && + searchQuery.trim().length > 1 && + deduplicatedSearchResults.length === 0 && ( +
{ + field.onChange(searchQuery); + setSearchResults([]); + setPopoverOpen(false); + }} + > + {`Create "${searchQuery}"`} +
+ )} +
+
+ )} +
+
+ +
+ )} + /> + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form-schema.ts b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form-schema.ts new file mode 100644 index 000000000..065d81151 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form-schema.ts @@ -0,0 +1,18 @@ +import { VendorCategory, VendorStatus } from '@db'; +import { z } from 'zod'; + +export const createVendorSchema = z.object({ + name: z.string().trim().min(1, 'Name is required'), + // Allow empty string in the input and treat it as "not provided" + website: z + .union([z.string().url('URL must be valid and start with https://'), z.literal('')]) + .transform((value) => (value === '' ? undefined : value)) + .optional(), + description: z.string().optional(), + category: z.nativeEnum(VendorCategory), + status: z.nativeEnum(VendorStatus), + assigneeId: z.string().optional(), +}); + +export type CreateVendorFormValues = z.infer; + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx index fe4f64e5e..2ecf0ac42 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx @@ -1,40 +1,25 @@ 'use client'; import { researchVendorAction } from '@/actions/research-vendor'; +import type { ActionResponse } from '@/types/actions'; import { SelectAssignee } from '@/components/SelectAssignee'; -import { useDebouncedCallback } from '@/hooks/use-debounced-callback'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@comp/ui/accordion'; import { Button } from '@comp/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; import { Input } from '@comp/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { Textarea } from '@comp/ui/textarea'; -import type { GlobalVendors } from '@db'; -import { type Member, type User, VendorCategory, VendorStatus } from '@db'; +import { type Member, type User, type Vendor, VendorCategory, VendorStatus } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowRightIcon } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; import { useQueryState } from 'nuqs'; -import { useState } from 'react'; +import { useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { useSWRConfig } from 'swr'; -import { z } from 'zod'; import { createVendorAction } from '../actions/create-vendor-action'; -import { searchGlobalVendorsAction } from '../actions/search-global-vendors-action'; - -const createVendorSchema = z.object({ - name: z.string().min(1, 'Name is required'), - // Allow empty string in the input and treat it as "not provided" - website: z - .union([z.string().url('URL must be valid and start with https://'), z.literal('')]) - .transform((value) => (value === '' ? undefined : value)) - .optional(), - description: z.string().optional(), - category: z.nativeEnum(VendorCategory), - status: z.nativeEnum(VendorStatus), - assigneeId: z.string().optional(), -}); +import { VendorNameAutocompleteField } from './VendorNameAutocompleteField'; +import { createVendorSchema, type CreateVendorFormValues } from './create-vendor-form-schema'; export function CreateVendorForm({ assignees, @@ -44,56 +29,59 @@ export function CreateVendorForm({ organizationId: string; }) { const { mutate } = useSWRConfig(); - const [_, setCreateVendorSheet] = useQueryState('createVendorSheet'); + const [createVendorSheet, setCreateVendorSheet] = useQueryState('createVendorSheet'); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [popoverOpen, setPopoverOpen] = useState(false); + const isMountedRef = useRef(false); + const pendingWebsiteRef = useRef(null); const createVendor = useAction(createVendorAction, { - onSuccess: async () => { - toast.success('Vendor created successfully'); - setCreateVendorSheet(null); - // Invalidate all vendors SWR caches (any key starting with 'vendors') + onSuccess: async (result) => { + const response = result.data as ActionResponse | undefined; + + // Check if the action returned success: false (e.g., duplicate vendor) + if (response && response.success === false) { + pendingWebsiteRef.current = null; + const errorMessage = response.error || 'Failed to create vendor'; + toast.error(errorMessage); + return; + } + + // If we get here, vendor was created successfully + + // Run optional follow-up research FIRST (non-blocking) + const website = pendingWebsiteRef.current; + pendingWebsiteRef.current = null; + if (website) { + // Fire and forget - non-blocking + researchVendor.execute({ website }); + } + + // Invalidate vendors cache mutate( (key) => Array.isArray(key) && key[0] === 'vendors', undefined, { revalidate: true }, ); - }, - onError: () => { - toast.error('Failed to create vendor'); - }, - }); - - const researchVendor = useAction(researchVendorAction); - const searchVendors = useAction(searchGlobalVendorsAction, { - onExecute: () => setIsSearching(true), - onSuccess: (result) => { - if (result.data?.success && result.data.data?.vendors) { - setSearchResults(result.data.data.vendors); - } else { - setSearchResults([]); - } - setIsSearching(false); + // Show success toast + toast.success('Vendor created successfully'); + + // Close sheet last - use setTimeout to ensure it happens after all state updates + setTimeout(() => { + setCreateVendorSheet(null); + }, 0); }, - onError: () => { - setSearchResults([]); - setIsSearching(false); + onError: (error) => { + // Handle thrown errors (shouldn't happen with our try-catch, but keep as fallback) + const errorMessage = error.error?.serverError || 'Failed to create vendor'; + pendingWebsiteRef.current = null; + toast.error(errorMessage); }, }); - const debouncedSearch = useDebouncedCallback((query: string) => { - if (query.trim().length > 1) { - searchVendors.execute({ name: query }); - } else { - setSearchResults([]); - } - }, 300); + const researchVendor = useAction(researchVendorAction); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(createVendorSchema), defaultValues: { name: '', @@ -105,254 +93,156 @@ export function CreateVendorForm({ mode: 'onChange', }); - const onSubmit = async (data: z.infer) => { - createVendor.execute({ ...data, organizationId }); - - if (data.website) { - await researchVendor.execute({ - website: data.website, + // Reset form state when sheet closes + useEffect(() => { + const isOpen = Boolean(createVendorSheet); + + if (!isOpen && isMountedRef.current) { + // Sheet was closed - reset all state + form.reset({ + name: '', + website: '', + description: '', + category: VendorCategory.cloud, + status: VendorStatus.not_assessed, }); + } else if (isOpen) { + // Sheet opened - mark as mounted + isMountedRef.current = true; } - }; + }, [createVendorSheet, form]); + + const onSubmit = async (data: CreateVendorFormValues) => { + // Prevent double-submits (also disabled via button state) + if (createVendor.status === 'executing') return; - const handleSelectVendor = (vendor: GlobalVendors) => { - form.setValue('name', vendor.company_name ?? vendor.legal_name ?? ''); - form.setValue('website', vendor.website ?? ''); - form.setValue('description', vendor.company_description ?? ''); - setSearchQuery(vendor.company_name ?? vendor.legal_name ?? ''); - setSearchResults([]); - setPopoverOpen(false); + pendingWebsiteRef.current = data.website ?? null; + createVendor.execute({ ...data, organizationId }); }; return (
-
-
- - - {'Vendor Details'} - -
- ( - - {'Vendor Name'} - -
- { - const val = e.target.value; - setSearchQuery(val); - field.onChange(val); - debouncedSearch(val); - if (val.trim().length > 1) { - setPopoverOpen(true); - } else { - setPopoverOpen(false); - setSearchResults([]); - } - }} - onBlur={() => { - setTimeout(() => setPopoverOpen(false), 150); - }} - onFocus={() => { - if ( - searchQuery.trim().length > 1 && - (isSearching || - searchResults.length > 0 || - (!isSearching && searchResults.length === 0)) - ) { - setPopoverOpen(true); - } - }} - autoFocus - /> - {popoverOpen && ( -
-
- {isSearching && ( -
- {'Loading...'}... -
- )} - {!isSearching && searchResults.length > 0 && ( - <> -

- {'Suggestions'} -

- {searchResults.map((vendor) => ( -
{ - handleSelectVendor(vendor); - setPopoverOpen(false); - }} - > - {vendor.company_name ?? - vendor.legal_name ?? - vendor.website} -
- ))} - - )} - {!isSearching && - searchQuery.trim().length > 1 && - searchResults.length === 0 && ( -
{ - field.onChange(searchQuery); - setSearchResults([]); - setPopoverOpen(false); - }} - > - {`Create "${searchQuery}"`} -
- )} -
-
- )} -
-
- -
- )} - /> - ( - - {'Website'} - - - - - - )} - /> - ( - - {'Description'} - -