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 { Textarea } from "@/components/ui/textarea";
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";
export interface AiDescriptionCompareProps {
@@ -28,6 +28,10 @@ export interface AiDescriptionCompareProps {
issues: string[];
onAccept: (editedSuggestion: string) => void;
onDismiss: () => void;
/** Called to re-roll (re-run) the AI validation */
onRevalidate?: () => void;
/** Whether re-validation is in progress */
isRevalidating?: boolean;
productName?: string;
className?: string;
}
@@ -39,6 +43,8 @@ export function AiDescriptionCompare({
issues,
onAccept,
onDismiss,
onRevalidate,
isRevalidating = false,
productName,
className,
}: AiDescriptionCompareProps) {
@@ -226,6 +232,18 @@ export function AiDescriptionCompare({
>
Ignore
</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>

View File

@@ -218,6 +218,7 @@ export function ProductEditForm({
const watchCompany = watch("company");
const watchLine = watch("line");
const watchDescription = watch("description");
// Populate form on mount
useEffect(() => {
@@ -452,7 +453,7 @@ export function ProductEditForm({
const handleValidateDescription = useCallback(async () => {
const values = getValues();
if (!values.description?.trim()) return;
if (!values.description?.trim() || values.description.trim().length < 10) return;
clearDescriptionResult();
setValidatingField("description");
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 className="flex items-center justify-between relative">
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
{isDescription && (
{isDescription && (watchDescription?.trim().length ?? 0) >= 10 && (
descriptionResult?.suggestion ? (
<button
type="button"
@@ -632,11 +633,13 @@ export function ProductEditForm({
<AiSuggestionBadge
suggestion={nameResult.suggestion}
issues={nameResult.issues}
onAccept={() => {
setValue("name", nameResult.suggestion!, { shouldDirty: true });
onAccept={(editedValue) => {
setValue("name", editedValue, { shouldDirty: true });
clearNameResult();
}}
onDismiss={clearNameResult}
onRevalidate={handleValidateName}
isRevalidating={validatingField === "name"}
compact
className="mt-1"
/>
@@ -762,6 +765,8 @@ export function ProductEditForm({
clearDescriptionResult();
setDescDialogOpen(false);
}}
onRevalidate={handleValidateDescription}
isRevalidating={validatingField === "description"}
/>
</DialogContent>
</Dialog>

View File

@@ -5,11 +5,11 @@
* Used for inline validation suggestions on Name and Description fields.
*
* 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 { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@@ -24,10 +24,14 @@ interface AiSuggestionBadgeProps {
suggestion: string;
/** List of issues found (optional) */
issues?: string[];
/** Called when user accepts the suggestion */
onAccept: () => void;
/** Called when user accepts the suggestion (receives the possibly-edited value) */
onAccept: (editedValue: string) => void;
/** Called when user dismisses the suggestion */
onDismiss: () => void;
/** Called to refresh (re-run) the AI validation */
onRevalidate?: () => void;
/** Whether re-validation is in progress */
isRevalidating?: boolean;
/** Additional CSS classes */
className?: string;
/** Whether to show the suggestion as compact (inline) - used for name field */
@@ -41,13 +45,21 @@ export function AiSuggestionBadge({
issues = [],
onAccept,
onDismiss,
onRevalidate,
isRevalidating = false,
className,
compact = false,
collapsible = false
}: AiSuggestionBadgeProps) {
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) {
return (
<div
@@ -58,24 +70,27 @@ export function AiSuggestionBadge({
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" />
<span className="text-purple-700 dark:text-purple-300">
{suggestion}
</span>
<input
type="text"
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 className="flex items-center gap-0.5 flex-shrink-0">
<div className="flex items-center gap-[0px] flex-shrink-0">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
size="sm"
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) => {
e.stopPropagation();
onAccept();
onAccept(editedValue);
}}
>
<Check className="h-3 w-3" />
@@ -92,7 +107,7 @@ export function AiSuggestionBadge({
<Button
size="sm"
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) => {
e.stopPropagation();
onDismiss();
@@ -106,19 +121,46 @@ export function AiSuggestionBadge({
</TooltipContent>
</Tooltip>
</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 */}
{issues.length > 0 && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors"
onClick={(e) => e.stopPropagation()}
<Button
variant="ghost"
size="sm"
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" />
</button>
</TooltipTrigger>
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
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"
onClick={(e) => {
e.stopPropagation();
onAccept();
onAccept(suggestion);
}}
>
<Check className="h-3 w-3 mr-1" />

View File

@@ -588,9 +588,9 @@ const CellWrapper = memo(({
// Check if description should be validated
const descIsDismissed = nameSuggestion?.dismissed?.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
setInlineAiValidating(`${contextProductIndex}-description`, true);
@@ -687,7 +687,9 @@ const CellWrapper = memo(({
// Trigger inline AI validation for name/description fields
// This validates spelling, grammar, and naming conventions using Groq
// 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 fields = useValidationStore.getState().fields;
if (currentRow) {
@@ -751,6 +753,66 @@ const CellWrapper = memo(({
}, 0);
}, [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)
const handleFetchOptions = useCallback(async () => {
const state = useValidationStore.getState();
@@ -854,6 +916,7 @@ const CellWrapper = memo(({
onDismissAiSuggestion: () => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
},
onRevalidate: handleRevalidate,
})}
/>
</div>
@@ -925,12 +988,18 @@ const CellWrapper = memo(({
<AiSuggestionBadge
suggestion={fieldSuggestion.suggestion!}
issues={fieldSuggestion.issues}
onAccept={() => {
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
onAccept={(editedValue) => {
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={() => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
}}
onRevalidate={handleRevalidate}
isRevalidating={isInlineAiValidating}
compact
/>
</div>

View File

@@ -51,6 +51,8 @@ interface MultilineInputProps {
isAiValidating?: boolean;
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
onDismissAiSuggestion?: () => void;
/** Called to manually trigger AI re-validation */
onRevalidate?: () => void;
}
const MultilineInputComponent = ({
@@ -64,6 +66,7 @@ const MultilineInputComponent = ({
aiSuggestion,
isAiValidating,
onDismissAiSuggestion,
onRevalidate,
}: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
@@ -408,6 +411,8 @@ const MultilineInputComponent = ({
productName={productName}
onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion}
onRevalidate={onRevalidate}
isRevalidating={isAiValidating}
/>
) : (
<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' &&
row.name.trim();
// Check description context: company + line + name (description can be empty)
// We want to validate descriptions even when empty so AI can suggest one
const hasDescContext = hasNameContext;
// Check description context: company + line + name + description with ≥10 chars
const descriptionValue = typeof row.description === 'string' ? row.description.trim() : '';
const hasDescContext = hasNameContext && descriptionValue.length >= 10;
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);