Don't validate empty descriptions, other validation enhancements

This commit is contained in:
2026-03-11 16:23:20 -04:00
parent f887dc6af1
commit 177f7778b9
6 changed files with 174 additions and 35 deletions

View File

@@ -18,7 +18,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sparkles, AlertCircle, Check } from "lucide-react"; import { Sparkles, AlertCircle, Check, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface AiDescriptionCompareProps { export interface AiDescriptionCompareProps {
@@ -28,6 +28,10 @@ export interface AiDescriptionCompareProps {
issues: string[]; issues: string[];
onAccept: (editedSuggestion: string) => void; onAccept: (editedSuggestion: string) => void;
onDismiss: () => void; onDismiss: () => void;
/** Called to re-roll (re-run) the AI validation */
onRevalidate?: () => void;
/** Whether re-validation is in progress */
isRevalidating?: boolean;
productName?: string; productName?: string;
className?: string; className?: string;
} }
@@ -39,6 +43,8 @@ export function AiDescriptionCompare({
issues, issues,
onAccept, onAccept,
onDismiss, onDismiss,
onRevalidate,
isRevalidating = false,
productName, productName,
className, className,
}: AiDescriptionCompareProps) { }: AiDescriptionCompareProps) {
@@ -226,6 +232,18 @@ export function AiDescriptionCompare({
> >
Ignore Ignore
</Button> </Button>
{onRevalidate && (
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400"
disabled={isRevalidating}
onClick={onRevalidate}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isRevalidating ? "animate-spin" : ""}`} />
Refresh
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -218,6 +218,7 @@ export function ProductEditForm({
const watchCompany = watch("company"); const watchCompany = watch("company");
const watchLine = watch("line"); const watchLine = watch("line");
const watchDescription = watch("description");
// Populate form on mount // Populate form on mount
useEffect(() => { useEffect(() => {
@@ -452,7 +453,7 @@ export function ProductEditForm({
const handleValidateDescription = useCallback(async () => { const handleValidateDescription = useCallback(async () => {
const values = getValues(); const values = getValues();
if (!values.description?.trim()) return; if (!values.description?.trim() || values.description.trim().length < 10) return;
clearDescriptionResult(); clearDescriptionResult();
setValidatingField("description"); setValidatingField("description");
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label; const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
@@ -550,7 +551,7 @@ export function ProductEditForm({
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors"> <div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between relative"> <div className="flex items-center justify-between relative">
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span> <span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
{isDescription && ( {isDescription && (watchDescription?.trim().length ?? 0) >= 10 && (
descriptionResult?.suggestion ? ( descriptionResult?.suggestion ? (
<button <button
type="button" type="button"
@@ -632,11 +633,13 @@ export function ProductEditForm({
<AiSuggestionBadge <AiSuggestionBadge
suggestion={nameResult.suggestion} suggestion={nameResult.suggestion}
issues={nameResult.issues} issues={nameResult.issues}
onAccept={() => { onAccept={(editedValue) => {
setValue("name", nameResult.suggestion!, { shouldDirty: true }); setValue("name", editedValue, { shouldDirty: true });
clearNameResult(); clearNameResult();
}} }}
onDismiss={clearNameResult} onDismiss={clearNameResult}
onRevalidate={handleValidateName}
isRevalidating={validatingField === "name"}
compact compact
className="mt-1" className="mt-1"
/> />
@@ -762,6 +765,8 @@ export function ProductEditForm({
clearDescriptionResult(); clearDescriptionResult();
setDescDialogOpen(false); setDescDialogOpen(false);
}} }}
onRevalidate={handleValidateDescription}
isRevalidating={validatingField === "description"}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -5,11 +5,11 @@
* Used for inline validation suggestions on Name and Description fields. * Used for inline validation suggestions on Name and Description fields.
* *
* For description fields, starts collapsed (just icon + count) and expands on click. * For description fields, starts collapsed (just icon + count) and expands on click.
* For name fields, uses compact inline mode. * For name fields, uses compact inline mode with an editable suggestion.
*/ */
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react'; import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Tooltip, Tooltip,
@@ -24,10 +24,14 @@ interface AiSuggestionBadgeProps {
suggestion: string; suggestion: string;
/** List of issues found (optional) */ /** List of issues found (optional) */
issues?: string[]; issues?: string[];
/** Called when user accepts the suggestion */ /** Called when user accepts the suggestion (receives the possibly-edited value) */
onAccept: () => void; onAccept: (editedValue: string) => void;
/** Called when user dismisses the suggestion */ /** Called when user dismisses the suggestion */
onDismiss: () => void; onDismiss: () => void;
/** Called to refresh (re-run) the AI validation */
onRevalidate?: () => void;
/** Whether re-validation is in progress */
isRevalidating?: boolean;
/** Additional CSS classes */ /** Additional CSS classes */
className?: string; className?: string;
/** Whether to show the suggestion as compact (inline) - used for name field */ /** Whether to show the suggestion as compact (inline) - used for name field */
@@ -41,13 +45,21 @@ export function AiSuggestionBadge({
issues = [], issues = [],
onAccept, onAccept,
onDismiss, onDismiss,
onRevalidate,
isRevalidating = false,
className, className,
compact = false, compact = false,
collapsible = false collapsible = false
}: AiSuggestionBadgeProps) { }: AiSuggestionBadgeProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [editedValue, setEditedValue] = useState(suggestion);
// Compact mode for name fields - inline suggestion with accept/dismiss // Reset edited value when suggestion changes (e.g. after refresh)
useEffect(() => {
setEditedValue(suggestion);
}, [suggestion]);
// Compact mode for name fields - inline editable suggestion with accept/dismiss
if (compact) { if (compact) {
return ( return (
<div <div
@@ -58,24 +70,27 @@ export function AiSuggestionBadge({
className className
)} )}
> >
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5 flex-1 min-w-0">
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" /> <Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" />
<span className="text-purple-700 dark:text-purple-300"> <input
{suggestion} type="text"
</span> value={editedValue}
onChange={(e) => setEditedValue(e.target.value)}
className="flex-1 min-w-0 bg-transparent text-purple-700 dark:text-purple-300 text-xs outline-none border-b border-transparent focus:border-purple-300 dark:focus:border-purple-600 transition-colors"
/>
</div> </div>
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-[0px] flex-shrink-0">
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100" className="h-4 w-4 p-0 [&_svg]:size-3.5 text-green-600 hover:text-green-700 hover:bg-green-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAccept(); onAccept(editedValue);
}} }}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
@@ -92,7 +107,7 @@ export function AiSuggestionBadge({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100" className="h-4 w-4 p-0 [&_svg]:size-3.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDismiss(); onDismiss();
@@ -106,19 +121,46 @@ export function AiSuggestionBadge({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
{/* Refresh button */}
{onRevalidate && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-4 w-4 p-0 mr-[1px] [&_svg]:size-3 text-purple-400 hover:text-purple-600 hover:bg-purple-100"
disabled={isRevalidating}
onClick={(e) => {
e.stopPropagation();
onRevalidate();
}}
>
<RefreshCw className={cn(isRevalidating && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Refresh suggestion</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Info icon with issues tooltip */} {/* Info icon with issues tooltip */}
{issues.length > 0 && ( {issues.length > 0 && (
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" variant="ghost"
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors" size="sm"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation();
}}
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-purple-400 hover:text-purple-600 hover:bg-purple-100"
> >
<Info className="h-3.5 w-3.5" /> <Info className="h-3.5 w-3.5" />
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
side="top" side="top"
align="start" align="start"
@@ -246,7 +288,7 @@ export function AiSuggestionBadge({
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400" className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAccept(); onAccept(suggestion);
}} }}
> >
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />

View File

@@ -588,9 +588,9 @@ const CellWrapper = memo(({
// Check if description should be validated // Check if description should be validated
const descIsDismissed = nameSuggestion?.dismissed?.description; const descIsDismissed = nameSuggestion?.dismissed?.description;
const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`); const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`);
const descValue = currentRowForContext.description && String(currentRowForContext.description).trim(); const descValue = currentRowForContext.description ? String(currentRowForContext.description).trim() : '';
if (descValue && !descIsDismissed && !descIsValidating) { if (descValue.length >= 10 && !descIsDismissed && !descIsValidating) {
// Trigger description validation // Trigger description validation
setInlineAiValidating(`${contextProductIndex}-description`, true); setInlineAiValidating(`${contextProductIndex}-description`, true);
@@ -687,7 +687,9 @@ const CellWrapper = memo(({
// Trigger inline AI validation for name/description fields // Trigger inline AI validation for name/description fields
// This validates spelling, grammar, and naming conventions using Groq // This validates spelling, grammar, and naming conventions using Groq
// Only trigger if value actually changed to avoid unnecessary API calls // Only trigger if value actually changed to avoid unnecessary API calls
if (isInlineAiField && valueChanged && valueToSave && String(valueToSave).trim()) { const trimmedValue = valueToSave ? String(valueToSave).trim() : '';
const meetsMinLength = field.key === 'description' ? trimmedValue.length >= 10 : trimmedValue.length > 0;
if (isInlineAiField && valueChanged && meetsMinLength) {
const currentRow = useValidationStore.getState().rows[rowIndex]; const currentRow = useValidationStore.getState().rows[rowIndex];
const fields = useValidationStore.getState().fields; const fields = useValidationStore.getState().fields;
if (currentRow) { if (currentRow) {
@@ -751,6 +753,66 @@ const CellWrapper = memo(({
}, 0); }, 0);
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]); }, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
// Manual re-validate: triggers inline AI validation regardless of value changes
const handleRevalidate = useCallback(() => {
if (!isInlineAiField) return;
const state = useValidationStore.getState();
const currentRow = state.rows[rowIndex];
if (!currentRow) return;
const fieldKey = field.key as 'name' | 'description';
const currentValue = String(currentRow[fieldKey] ?? '').trim();
// Name requires non-empty, description requires ≥10 chars
if (fieldKey === 'name' && !currentValue) return;
if (fieldKey === 'description' && currentValue.length < 10) return;
const validationKey = `${productIndex}-${fieldKey}`;
if (state.inlineAi.validating.has(validationKey)) return;
const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, fields: storeFields, rows } = state;
setInlineAiValidating(validationKey, true);
markInlineAiAutoValidated(productIndex, fieldKey);
// Clear dismissed state so new result shows
const suggestions = state.inlineAi.suggestions.get(productIndex);
if (suggestions?.dismissed?.[fieldKey]) {
// Reset dismissed by re-setting suggestion (will be overwritten by API result)
setInlineAiSuggestion(productIndex, fieldKey, {
isValid: true,
suggestion: undefined,
issues: [],
});
}
const payload = fieldKey === 'name'
? buildNameValidationPayload(currentRow, storeFields, rows)
: buildDescriptionValidationPayload(currentRow, storeFields);
const endpoint = fieldKey === 'name'
? '/api/ai/validate/inline/name'
: '/api/ai/validate/inline/description';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: payload }),
})
.then(res => res.json())
.then(result => {
if (result.success !== false) {
setInlineAiSuggestion(productIndex, fieldKey, {
isValid: result.isValid ?? true,
suggestion: result.suggestion,
issues: result.issues || [],
latencyMs: result.latencyMs,
});
}
})
.catch(err => console.error(`[InlineAI] manual ${fieldKey} revalidation error:`, err))
.finally(() => setInlineAiValidating(validationKey, false));
}, [rowIndex, field.key, isInlineAiField, productIndex]);
// Stable callback for fetching options (for line/subline dropdowns) // Stable callback for fetching options (for line/subline dropdowns)
const handleFetchOptions = useCallback(async () => { const handleFetchOptions = useCallback(async () => {
const state = useValidationStore.getState(); const state = useValidationStore.getState();
@@ -854,6 +916,7 @@ const CellWrapper = memo(({
onDismissAiSuggestion: () => { onDismissAiSuggestion: () => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description'); useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
}, },
onRevalidate: handleRevalidate,
})} })}
/> />
</div> </div>
@@ -925,12 +988,18 @@ const CellWrapper = memo(({
<AiSuggestionBadge <AiSuggestionBadge
suggestion={fieldSuggestion.suggestion!} suggestion={fieldSuggestion.suggestion!}
issues={fieldSuggestion.issues} issues={fieldSuggestion.issues}
onAccept={() => { onAccept={(editedValue) => {
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name'); const state = useValidationStore.getState();
// Update the cell with the (possibly edited) value
state.updateCell(rowIndex, 'name', editedValue);
// Dismiss the suggestion
state.dismissInlineAiSuggestion(productIndex, 'name');
}} }}
onDismiss={() => { onDismiss={() => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name'); useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
}} }}
onRevalidate={handleRevalidate}
isRevalidating={isInlineAiValidating}
compact compact
/> />
</div> </div>

View File

@@ -51,6 +51,8 @@ interface MultilineInputProps {
isAiValidating?: boolean; isAiValidating?: boolean;
/** Called when user dismisses/clears the AI suggestion (also called after applying) */ /** Called when user dismisses/clears the AI suggestion (also called after applying) */
onDismissAiSuggestion?: () => void; onDismissAiSuggestion?: () => void;
/** Called to manually trigger AI re-validation */
onRevalidate?: () => void;
} }
const MultilineInputComponent = ({ const MultilineInputComponent = ({
@@ -64,6 +66,7 @@ const MultilineInputComponent = ({
aiSuggestion, aiSuggestion,
isAiValidating, isAiValidating,
onDismissAiSuggestion, onDismissAiSuggestion,
onRevalidate,
}: MultilineInputProps) => { }: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
@@ -408,6 +411,8 @@ const MultilineInputComponent = ({
productName={productName} productName={productName}
onAccept={handleAcceptSuggestion} onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion} onDismiss={handleDismissSuggestion}
onRevalidate={onRevalidate}
isRevalidating={isAiValidating}
/> />
) : ( ) : (
<div data-col="left" className="flex flex-col min-h-0 w-full"> <div data-col="left" className="flex flex-col min-h-0 w-full">

View File

@@ -108,9 +108,9 @@ export function useAutoInlineAiValidation() {
typeof row.name === 'string' && typeof row.name === 'string' &&
row.name.trim(); row.name.trim();
// Check description context: company + line + name (description can be empty) // Check description context: company + line + name + description with ≥10 chars
// We want to validate descriptions even when empty so AI can suggest one const descriptionValue = typeof row.description === 'string' ? row.description.trim() : '';
const hasDescContext = hasNameContext; const hasDescContext = hasNameContext && descriptionValue.length >= 10;
// Skip if already auto-validated (shouldn't happen on first run, but be safe) // Skip if already auto-validated (shouldn't happen on first run, but be safe)
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`); const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);