654 lines
23 KiB
TypeScript
654 lines
23 KiB
TypeScript
/**
|
|
* TaskEditDialog - Dialog for editing task details
|
|
*
|
|
* Allows users to modify all task properties including title, description,
|
|
* classification fields, images, and review settings.
|
|
* Follows the same dialog pattern as TaskCreationWizard for consistency.
|
|
*
|
|
* Features:
|
|
* - Pre-populates form with current task values
|
|
* - Form validation (description required)
|
|
* - Editable classification fields (category, priority, complexity, impact)
|
|
* - Editable image attachments (add/remove images)
|
|
* - Editable review settings (requireReviewBeforeCoding)
|
|
* - Saves changes via persistUpdateTask (updates store + spec files)
|
|
* - Prevents save when no changes have been made
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <TaskEditDialog
|
|
* task={selectedTask}
|
|
* open={isEditDialogOpen}
|
|
* onOpenChange={setIsEditDialogOpen}
|
|
* onSaved={() => console.log('Task updated!')}
|
|
* />
|
|
* ```
|
|
*/
|
|
import { useState, useEffect, useCallback, useRef, type ClipboardEvent, type DragEvent } from 'react';
|
|
import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp, X } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle
|
|
} from './ui/dialog';
|
|
import { Button } from './ui/button';
|
|
import { Input } from './ui/input';
|
|
import { Textarea } from './ui/textarea';
|
|
import { Label } from './ui/label';
|
|
import { Checkbox } from './ui/checkbox';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from './ui/select';
|
|
import {
|
|
ImageUpload,
|
|
generateImageId,
|
|
blobToBase64,
|
|
createThumbnail,
|
|
isValidImageMimeType,
|
|
resolveFilename
|
|
} from './ImageUpload';
|
|
import { persistUpdateTask } from '../stores/task-store';
|
|
import { cn } from '../lib/utils';
|
|
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types';
|
|
import {
|
|
TASK_CATEGORY_LABELS,
|
|
TASK_PRIORITY_LABELS,
|
|
TASK_COMPLEXITY_LABELS,
|
|
TASK_IMPACT_LABELS,
|
|
MAX_IMAGES_PER_TASK,
|
|
ALLOWED_IMAGE_TYPES_DISPLAY
|
|
} from '../../shared/constants';
|
|
|
|
/**
|
|
* Props for the TaskEditDialog component
|
|
*/
|
|
interface TaskEditDialogProps {
|
|
/** The task to edit */
|
|
task: Task;
|
|
/** Whether the dialog is open */
|
|
open: boolean;
|
|
/** Callback when the dialog open state changes */
|
|
onOpenChange: (open: boolean) => void;
|
|
/** Optional callback when task is successfully saved */
|
|
onSaved?: () => void;
|
|
}
|
|
|
|
export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) {
|
|
const [title, setTitle] = useState(task.title);
|
|
const [description, setDescription] = useState(task.description);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [showImages, setShowImages] = useState(false);
|
|
const [pasteSuccess, setPasteSuccess] = useState(false);
|
|
|
|
// Classification fields
|
|
const [category, setCategory] = useState<TaskCategory | ''>(task.metadata?.category || '');
|
|
const [priority, setPriority] = useState<TaskPriority | ''>(task.metadata?.priority || '');
|
|
const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || '');
|
|
const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || '');
|
|
|
|
// Image attachments
|
|
const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []);
|
|
|
|
// Review setting
|
|
const [requireReviewBeforeCoding, setRequireReviewBeforeCoding] = useState(
|
|
task.metadata?.requireReviewBeforeCoding ?? false
|
|
);
|
|
|
|
// Ref for the textarea to handle paste events
|
|
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// Drag-and-drop state for images over textarea
|
|
const [isDragOverTextarea, setIsDragOverTextarea] = useState(false);
|
|
|
|
// Reset form when task changes or dialog opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
setTitle(task.title);
|
|
setDescription(task.description);
|
|
setCategory(task.metadata?.category || '');
|
|
setPriority(task.metadata?.priority || '');
|
|
setComplexity(task.metadata?.complexity || '');
|
|
setImpact(task.metadata?.impact || '');
|
|
setImages(task.metadata?.attachedImages || []);
|
|
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
|
|
setError(null);
|
|
|
|
// Auto-expand sections if they have content
|
|
if (task.metadata?.category || task.metadata?.priority || task.metadata?.complexity || task.metadata?.impact) {
|
|
setShowAdvanced(true);
|
|
}
|
|
// Auto-expand images section if task has images
|
|
setShowImages((task.metadata?.attachedImages || []).length > 0);
|
|
setPasteSuccess(false);
|
|
}
|
|
}, [open, task]);
|
|
|
|
/**
|
|
* Handle paste event for screenshot support
|
|
*/
|
|
const handlePaste = useCallback(async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
const clipboardItems = e.clipboardData?.items;
|
|
if (!clipboardItems) return;
|
|
|
|
// Find image items in clipboard
|
|
const imageItems: DataTransferItem[] = [];
|
|
for (let i = 0; i < clipboardItems.length; i++) {
|
|
const item = clipboardItems[i];
|
|
if (item.type.startsWith('image/')) {
|
|
imageItems.push(item);
|
|
}
|
|
}
|
|
|
|
// If no images, allow normal paste behavior
|
|
if (imageItems.length === 0) return;
|
|
|
|
// Prevent default paste when we have images
|
|
e.preventDefault();
|
|
|
|
// Check if we can add more images
|
|
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
|
|
if (remainingSlots <= 0) {
|
|
setError(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`);
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
|
|
// Process image items
|
|
const newImages: ImageAttachment[] = [];
|
|
const existingFilenames = images.map(img => img.filename);
|
|
|
|
for (const item of imageItems.slice(0, remainingSlots)) {
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
|
|
// Validate image type
|
|
if (!isValidImageMimeType(file.type)) {
|
|
setError(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const dataUrl = await blobToBase64(file);
|
|
const thumbnail = await createThumbnail(dataUrl);
|
|
|
|
// Generate filename for pasted images (screenshot-timestamp.ext)
|
|
const extension = file.type.split('/')[1] || 'png';
|
|
const baseFilename = `screenshot-${Date.now()}.${extension}`;
|
|
const resolvedFilename = resolveFilename(baseFilename, [
|
|
...existingFilenames,
|
|
...newImages.map(img => img.filename)
|
|
]);
|
|
|
|
newImages.push({
|
|
id: generateImageId(),
|
|
filename: resolvedFilename,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
|
|
thumbnail
|
|
});
|
|
} catch {
|
|
setError('Failed to process pasted image');
|
|
}
|
|
}
|
|
|
|
if (newImages.length > 0) {
|
|
setImages(prev => [...prev, ...newImages]);
|
|
// Auto-expand images section
|
|
setShowImages(true);
|
|
// Show success feedback
|
|
setPasteSuccess(true);
|
|
setTimeout(() => setPasteSuccess(false), 2000);
|
|
}
|
|
}, [images]);
|
|
|
|
/**
|
|
* Handle drag over textarea for image drops
|
|
*/
|
|
const handleTextareaDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOverTextarea(true);
|
|
}, []);
|
|
|
|
/**
|
|
* Handle drag leave from textarea
|
|
*/
|
|
const handleTextareaDragLeave = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOverTextarea(false);
|
|
}, []);
|
|
|
|
/**
|
|
* Handle drop on textarea for image files
|
|
*/
|
|
const handleTextareaDrop = useCallback(
|
|
async (e: DragEvent<HTMLTextAreaElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOverTextarea(false);
|
|
|
|
if (isSaving) return;
|
|
|
|
const files = e.dataTransfer?.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
// Filter for image files
|
|
const imageFiles: File[] = [];
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
if (file.type.startsWith('image/')) {
|
|
imageFiles.push(file);
|
|
}
|
|
}
|
|
|
|
if (imageFiles.length === 0) return;
|
|
|
|
// Check if we can add more images
|
|
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
|
|
if (remainingSlots <= 0) {
|
|
setError(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`);
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
|
|
// Process image files
|
|
const newImages: ImageAttachment[] = [];
|
|
const existingFilenames = images.map(img => img.filename);
|
|
|
|
for (const file of imageFiles.slice(0, remainingSlots)) {
|
|
// Validate image type
|
|
if (!isValidImageMimeType(file.type)) {
|
|
setError(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const dataUrl = await blobToBase64(file);
|
|
const thumbnail = await createThumbnail(dataUrl);
|
|
|
|
// Use original filename or generate one
|
|
const baseFilename = file.name || `dropped-image-${Date.now()}.${file.type.split('/')[1] || 'png'}`;
|
|
const resolvedFilename = resolveFilename(baseFilename, [
|
|
...existingFilenames,
|
|
...newImages.map(img => img.filename)
|
|
]);
|
|
|
|
newImages.push({
|
|
id: generateImageId(),
|
|
filename: resolvedFilename,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
|
|
thumbnail
|
|
});
|
|
} catch {
|
|
setError('Failed to process dropped image');
|
|
}
|
|
}
|
|
|
|
if (newImages.length > 0) {
|
|
setImages(prev => [...prev, ...newImages]);
|
|
// Auto-expand images section
|
|
setShowImages(true);
|
|
// Show success feedback
|
|
setPasteSuccess(true);
|
|
setTimeout(() => setPasteSuccess(false), 2000);
|
|
}
|
|
},
|
|
[images, isSaving]
|
|
);
|
|
|
|
const handleSave = async () => {
|
|
// Validate input - only description is required
|
|
if (!description.trim()) {
|
|
setError('Description is required');
|
|
return;
|
|
}
|
|
|
|
// Check if anything changed
|
|
const trimmedTitle = title.trim();
|
|
const trimmedDescription = description.trim();
|
|
const hasChanges =
|
|
trimmedTitle !== task.title ||
|
|
trimmedDescription !== task.description ||
|
|
category !== (task.metadata?.category || '') ||
|
|
priority !== (task.metadata?.priority || '') ||
|
|
complexity !== (task.metadata?.complexity || '') ||
|
|
impact !== (task.metadata?.impact || '') ||
|
|
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
|
|
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []);
|
|
|
|
if (!hasChanges) {
|
|
// No changes, just close
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
|
|
// Build metadata updates
|
|
const metadataUpdates: Partial<typeof task.metadata> = {};
|
|
if (category) metadataUpdates.category = category;
|
|
if (priority) metadataUpdates.priority = priority;
|
|
if (complexity) metadataUpdates.complexity = complexity;
|
|
if (impact) metadataUpdates.impact = impact;
|
|
if (images.length > 0) metadataUpdates.attachedImages = images;
|
|
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
|
|
|
|
// Title is optional - if empty, it will be auto-generated by the backend
|
|
const success = await persistUpdateTask(task.id, {
|
|
title: trimmedTitle,
|
|
description: trimmedDescription,
|
|
metadata: metadataUpdates
|
|
});
|
|
|
|
if (success) {
|
|
onOpenChange(false);
|
|
onSaved?.();
|
|
} else {
|
|
setError('Failed to update task. Please try again.');
|
|
}
|
|
|
|
setIsSaving(false);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (!isSaving) {
|
|
onOpenChange(false);
|
|
}
|
|
};
|
|
|
|
// Only description is required - title will be auto-generated if empty
|
|
const isValid = description.trim().length > 0;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-foreground">Edit Task</DialogTitle>
|
|
<DialogDescription>
|
|
Update task details including title, description, classification, images, and settings. Changes will be saved to the spec files.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-4">
|
|
{/* Description (Primary - Required) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-description" className="text-sm font-medium text-foreground">
|
|
Description <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
ref={descriptionRef}
|
|
id="edit-description"
|
|
placeholder="Describe the feature, bug fix, or improvement. Be as specific as possible about requirements, constraints, and expected behavior."
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
onPaste={handlePaste}
|
|
onDragOver={handleTextareaDragOver}
|
|
onDragLeave={handleTextareaDragLeave}
|
|
onDrop={handleTextareaDrop}
|
|
rows={5}
|
|
disabled={isSaving}
|
|
className={cn(
|
|
isDragOverTextarea && !isSaving && "border-primary bg-primary/5 ring-2 ring-primary/20"
|
|
)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Tip: Paste screenshots directly with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'} to add reference images.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Title (Optional - Auto-generated if empty) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-title" className="text-sm font-medium text-foreground">
|
|
Task Title <span className="text-muted-foreground font-normal">(optional)</span>
|
|
</Label>
|
|
<Input
|
|
id="edit-title"
|
|
placeholder="Leave empty to auto-generate from description"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
disabled={isSaving}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
A short, descriptive title will be generated automatically if left empty.
|
|
</p>
|
|
</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" />
|
|
Image added successfully!
|
|
</div>
|
|
)}
|
|
|
|
{/* Advanced Options Toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
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={isSaving}
|
|
>
|
|
<span>Classification (optional)</span>
|
|
{showAdvanced ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Advanced Options */}
|
|
{showAdvanced && (
|
|
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Category */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-category" className="text-xs font-medium text-muted-foreground">
|
|
Category
|
|
</Label>
|
|
<Select
|
|
value={category}
|
|
onValueChange={(value) => setCategory(value as TaskCategory)}
|
|
disabled={isSaving}
|
|
>
|
|
<SelectTrigger id="edit-category" className="h-9">
|
|
<SelectValue placeholder="Select category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(TASK_CATEGORY_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Priority */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-priority" className="text-xs font-medium text-muted-foreground">
|
|
Priority
|
|
</Label>
|
|
<Select
|
|
value={priority}
|
|
onValueChange={(value) => setPriority(value as TaskPriority)}
|
|
disabled={isSaving}
|
|
>
|
|
<SelectTrigger id="edit-priority" className="h-9">
|
|
<SelectValue placeholder="Select priority" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(TASK_PRIORITY_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Complexity */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-complexity" className="text-xs font-medium text-muted-foreground">
|
|
Complexity
|
|
</Label>
|
|
<Select
|
|
value={complexity}
|
|
onValueChange={(value) => setComplexity(value as TaskComplexity)}
|
|
disabled={isSaving}
|
|
>
|
|
<SelectTrigger id="edit-complexity" className="h-9">
|
|
<SelectValue placeholder="Select complexity" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(TASK_COMPLEXITY_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Impact */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-impact" className="text-xs font-medium text-muted-foreground">
|
|
Impact
|
|
</Label>
|
|
<Select
|
|
value={impact}
|
|
onValueChange={(value) => setImpact(value as TaskImpact)}
|
|
disabled={isSaving}
|
|
>
|
|
<SelectTrigger id="edit-impact" className="h-9">
|
|
<SelectValue placeholder="Select impact" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(TASK_IMPACT_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
These labels help organize and prioritize tasks. They're optional but useful for filtering.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Images Toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowImages(!showImages)}
|
|
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={isSaving}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<ImageIcon className="h-4 w-4" />
|
|
Reference Images (optional)
|
|
{images.length > 0 && (
|
|
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
|
{images.length}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{showImages ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Image Upload Section */}
|
|
{showImages && (
|
|
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
|
|
<p className="text-xs text-muted-foreground">
|
|
Attach screenshots, mockups, or diagrams to provide visual context for the AI.
|
|
</p>
|
|
<ImageUpload
|
|
images={images}
|
|
onImagesChange={setImages}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Review Requirement Toggle */}
|
|
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
|
|
<Checkbox
|
|
id="edit-require-review"
|
|
checked={requireReviewBeforeCoding}
|
|
onCheckedChange={(checked) => setRequireReviewBeforeCoding(checked === true)}
|
|
disabled={isSaving}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1 space-y-1">
|
|
<Label
|
|
htmlFor="edit-require-review"
|
|
className="text-sm font-medium text-foreground cursor-pointer"
|
|
>
|
|
Require human review before coding
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
When enabled, you'll be prompted to review the spec and implementation plan before the coding phase begins. This allows you to approve, request changes, or provide feedback.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
|
<X className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isSaving || !isValid}
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save Changes'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|