Don't validate empty descriptions, other validation enhancements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,18 +121,45 @@ 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>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user