Files
Aperant/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx
T
Andy df540ec512 refactor(ui): extract shared task form components for consistent moda… (#765)
* refactor(ui): extract shared task form components for consistent modal sizing

Create shared components to unify TaskCreationWizard, TaskEditDialog, and
TaskDetailModal with consistent full-height modal sizing.

New shared components in task-form/:
- TaskModalLayout: Full-height modal matching TaskDetailModal (95vw, max-w-5xl)
- TaskFormFields: Common form fields (description, title, profile, classification)
- ClassificationFields: Task classification 2x2 grid dropdowns
- useImageUpload: Hook for image paste/drop handling

Benefits:
- All 3 task modals now have identical dimensions and positioning
- Reduced code duplication (1,938 → 1,651 lines total)
- TaskCreationWizard: 1,176 → 623 lines (47% reduction)
- TaskEditDialog: 762 → 293 lines (62% reduction)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for task form components

- Fix HIGH: Pass descriptionRef from TaskCreationWizard to TaskFormFields
  to fix broken @ mention autocomplete positioning
- Fix MEDIUM: Add i18n translations for all hardcoded strings in:
  - ClassificationFields.tsx
  - TaskFormFields.tsx
  - TaskModalLayout.tsx
  - TaskCreationWizard.tsx (modal, draft, buttons, git options)
  - TaskEditDialog.tsx
- Fix MEDIUM: Correct isAutoProfile logic to only set true when
  profileId === 'auto' (not for all profiles with phase configs)
- Fix MEDIUM: Update handleAutocompleteSelect signature to accept
  optional fullPath parameter
- Fix LOW: Add proper setTimeout cleanup in useImageUpload.ts
- Fix LOW: Use queueMicrotask instead of setTimeout in
  handleAutocompleteSelect for cursor position restoration
- Fix LOW: Move fetch functions inside useEffect to fix
  exhaustive-deps warning
- Add English and French translations for all new i18n keys

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address all i18n violations and logic bug in task form components

i18n Fixes:
- Replace hardcoded error messages in TaskCreationWizard with translation keys
- Replace hardcoded error messages in TaskEditDialog with translation keys
- Use translated default placeholder in TaskFormFields
- Internationalize classification dropdown labels (category, priority,
  complexity, impact) using translation keys instead of hardcoded constants
- Add errorMessages parameter to useImageUpload hook for i18n support
- Pass translated error messages from TaskFormFields to useImageUpload

Logic Bug Fix:
- Fix image removal persistence in TaskEditDialog - always set attachedImages
  to persist removal when all images are deleted (was only set when length > 0)

Translation Updates:
- Add all missing translation keys to en/tasks.json and fr/tasks.json:
  - form.errors.* (descriptionRequired, maxImagesReached, etc.)
  - form.descriptionPlaceholder
  - form.classification.values.* (all classification option labels)
  - wizard.descriptionPlaceholder, wizard.errors.*
  - edit.errors.*

Other:
- Log image processing errors to console for debugging (CMT-001)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address memory leak and performance issues in task form components

- Add isMounted flag to useEffect in TaskCreationWizard to prevent state
  updates after component unmount (CMT-QUALITY-001)
- Wrap errorMessages merge in useMemo in useImageUpload to prevent
  unnecessary useCallback invalidation on re-renders (CMT-PERF-001)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: include phaseModels and phaseThinking in hasChanges check

TaskEditDialog's hasChanges logic was missing phaseModels and phaseThinking
comparisons, which could cause silent data loss when users only modified
phase configuration without changing other fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: preserve phaseModels and phaseThinking when editing non-autoProfile tasks

When editing a task with custom model/thinkingLevel that isn't an autoProfile,
the dialog was resetting phaseModels and phaseThinking to defaults instead of
preserving the task's actual values from metadata. This could cause data loss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:05:42 +01:00

348 lines
12 KiB
TypeScript

/**
* TaskFormFields - Shared form fields component for task create/edit
*
* Bundles the common form fields used in both TaskCreationWizard and TaskEditDialog:
* - Description (required, with image paste/drop support)
* - Title (optional)
* - Agent profile selector
* - Classification fields (collapsible)
* - Image thumbnails
* - Review requirement checkbox
*/
import { useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronUp, Image as ImageIcon, X } from 'lucide-react';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Checkbox } from '../ui/checkbox';
import { AgentProfileSelector } from '../AgentProfileSelector';
import { ClassificationFields } from './ClassificationFields';
import { useImageUpload } from './useImageUpload';
import { cn } from '../../lib/utils';
import type {
TaskCategory,
TaskPriority,
TaskComplexity,
TaskImpact,
ImageAttachment,
ModelType,
ThinkingLevel
} from '../../../shared/types';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../../shared/types/settings';
interface TaskFormFieldsProps {
// Description field
description: string;
onDescriptionChange: (value: string) => void;
descriptionPlaceholder?: string;
/** Optional custom content to render inside the description field (e.g., autocomplete popup) */
descriptionOverlay?: ReactNode;
/** Optional ref for the description textarea (used for @ mention autocomplete positioning) */
descriptionRef?: React.RefObject<HTMLTextAreaElement | null>;
// Title field
title: string;
onTitleChange: (value: string) => void;
// Agent profile
profileId: string;
model: ModelType | '';
thinkingLevel: ThinkingLevel | '';
phaseModels?: PhaseModelConfig;
phaseThinking?: PhaseThinkingConfig;
onProfileChange: (profileId: string, model: ModelType | '', thinkingLevel: ThinkingLevel | '') => void;
onModelChange: (model: ModelType | '') => void;
onThinkingLevelChange: (level: ThinkingLevel | '') => void;
onPhaseModelsChange: (config: PhaseModelConfig | undefined) => void;
onPhaseThinkingChange: (config: PhaseThinkingConfig | undefined) => void;
// Classification
category: TaskCategory | '';
priority: TaskPriority | '';
complexity: TaskComplexity | '';
impact: TaskImpact | '';
onCategoryChange: (value: TaskCategory | '') => void;
onPriorityChange: (value: TaskPriority | '') => void;
onComplexityChange: (value: TaskComplexity | '') => void;
onImpactChange: (value: TaskImpact | '') => void;
showClassification: boolean;
onShowClassificationChange: (show: boolean) => void;
// Images
images: ImageAttachment[];
onImagesChange: (images: ImageAttachment[]) => void;
// Review requirement
requireReviewBeforeCoding: boolean;
onRequireReviewChange: (require: boolean) => void;
// Form state
disabled?: boolean;
error?: string | null;
onError?: (error: string | null) => void;
// ID prefix for accessibility
idPrefix?: string;
/** Optional children to render after description (e.g., @ mention highlight overlay) */
children?: ReactNode;
}
export function TaskFormFields({
description,
onDescriptionChange,
descriptionPlaceholder,
descriptionOverlay,
descriptionRef: externalDescriptionRef,
title,
onTitleChange,
profileId,
model,
thinkingLevel,
phaseModels,
phaseThinking,
onProfileChange,
onModelChange,
onThinkingLevelChange,
onPhaseModelsChange,
onPhaseThinkingChange,
category,
priority,
complexity,
impact,
onCategoryChange,
onPriorityChange,
onComplexityChange,
onImpactChange,
showClassification,
onShowClassificationChange,
images,
onImagesChange,
requireReviewBeforeCoding,
onRequireReviewChange,
disabled = false,
error,
onError,
idPrefix = '',
children
}: TaskFormFieldsProps) {
const { t } = useTranslation(['tasks', 'common']);
// Use external ref if provided (for @ mention autocomplete), otherwise use internal ref
const internalDescriptionRef = useRef<HTMLTextAreaElement>(null);
const descriptionRef = externalDescriptionRef || internalDescriptionRef;
const prefix = idPrefix ? `${idPrefix}-` : '';
// Use the shared image upload hook with translated error messages
const {
isDragOver,
pasteSuccess,
handlePaste,
handleDragOver,
handleDragLeave,
handleDrop,
removeImage
} = useImageUpload({
images,
onImagesChange,
disabled,
onError,
errorMessages: {
maxImagesReached: t('tasks:form.errors.maxImagesReached'),
invalidImageType: t('tasks:form.errors.invalidImageType'),
processPasteFailed: t('tasks:form.errors.processPasteFailed'),
processDropFailed: t('tasks:form.errors.processDropFailed')
}
});
return (
<div className="space-y-6">
{/* Description (Primary - Required) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}description`} className="text-sm font-medium text-foreground">
{t('tasks:form.description')} <span className="text-destructive">*</span>
</Label>
<div className="relative">
{/* Optional overlay (e.g., @ mention highlighting) */}
{descriptionOverlay}
<Textarea
ref={descriptionRef}
id={`${prefix}description`}
placeholder={descriptionPlaceholder || t('tasks:form.descriptionPlaceholder')}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
rows={6}
disabled={disabled}
aria-required="true"
aria-describedby={`${prefix}description-help`}
className={cn(
'resize-y min-h-[150px] max-h-[400px] relative',
descriptionOverlay && 'bg-transparent',
isDragOver && !disabled && 'border-primary bg-primary/5 ring-2 ring-primary/20'
)}
style={descriptionOverlay ? { caretColor: 'auto' } : undefined}
/>
</div>
<p id={`${prefix}description-help`} className="text-xs text-muted-foreground">
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
</p>
{/* Image Thumbnails - displayed inline below description */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
style={{ width: '72px', height: '72px' }}
title={image.filename}
>
{image.thumbnail ? (
<img
src={image.thumbnail}
alt={image.filename}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<ImageIcon className="h-6 w-6 text-muted-foreground" />
</div>
)}
{/* Remove button */}
{!disabled && (
<button
type="button"
className="absolute top-0.5 right-0.5 h-5 w-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
aria-label={t('images.removeImageAriaLabel', { filename: image.filename })}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Optional children (e.g., @ mention autocomplete) */}
{children}
</div>
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
{t('tasks:form.imageAddedSuccess')}
</div>
)}
{/* Title (Optional) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
</Label>
<Input
id={`${prefix}title`}
placeholder={t('tasks:form.titlePlaceholder')}
value={title}
onChange={(e) => onTitleChange(e.target.value)}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{t('tasks:form.titleHelpText')}
</p>
</div>
{/* Agent Profile Selection */}
<AgentProfileSelector
profileId={profileId}
model={model}
thinkingLevel={thinkingLevel}
phaseModels={phaseModels}
phaseThinking={phaseThinking}
onProfileChange={onProfileChange}
onModelChange={onModelChange}
onThinkingLevelChange={onThinkingLevelChange}
onPhaseModelsChange={onPhaseModelsChange}
onPhaseThinkingChange={onPhaseThinkingChange}
disabled={disabled}
/>
{/* Classification Toggle */}
<button
type="button"
onClick={() => onShowClassificationChange(!showClassification)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={disabled}
aria-expanded={showClassification}
aria-controls={`${prefix}classification-section`}
>
<span>{t('tasks:form.classificationOptional')}</span>
{showClassification ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Classification Fields */}
{showClassification && (
<div id={`${prefix}classification-section`}>
<ClassificationFields
category={category}
priority={priority}
complexity={complexity}
impact={impact}
onCategoryChange={onCategoryChange}
onPriorityChange={onPriorityChange}
onComplexityChange={onComplexityChange}
onImpactChange={onImpactChange}
disabled={disabled}
idPrefix={idPrefix}
/>
</div>
)}
{/* Review Requirement Toggle */}
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
<Checkbox
id={`${prefix}require-review`}
checked={requireReviewBeforeCoding}
onCheckedChange={(checked) => onRequireReviewChange(checked === true)}
disabled={disabled}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<Label
htmlFor={`${prefix}require-review`}
className="text-sm font-medium text-foreground cursor-pointer"
>
{t('tasks:form.requireReviewLabel')}
</Label>
<p className="text-xs text-muted-foreground">
{t('tasks:form.requireReviewDescription')}
</p>
</div>
</div>
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
<X className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
);
}