feat(ui): add export and import features functionality

- Introduced new routes for exporting and importing features, enhancing project management capabilities.
- Added UI components for export and import dialogs, allowing users to easily manage feature data.
- Updated HTTP API client to support export and import operations with appropriate options and responses.
- Enhanced board view with controls for triggering export and import actions, improving user experience.
- Defined new types for feature export and import, ensuring type safety and clarity in data handling.
This commit is contained in:
Shirone
2026-01-21 13:00:34 +01:00
parent db71dc9aa5
commit 2214c2700b
16 changed files with 2431 additions and 15 deletions

View File

@@ -55,6 +55,8 @@ import {
FollowUpDialog,
PlanApprovalDialog,
PullResolveConflictsDialog,
ExportFeaturesDialog,
ImportFeaturesDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
@@ -234,6 +236,11 @@ export function BoardView() {
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
// Export/Import dialog states
const [showExportDialog, setShowExportDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [exportFeatureIds, setExportFeatureIds] = useState<string[] | undefined>(undefined);
// View mode state (kanban vs list)
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
@@ -1309,6 +1316,11 @@ export function BoardView() {
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onExportFeatures={() => {
setExportFeatureIds(undefined); // Export all features
setShowExportDialog(true);
}}
onImportFeatures={() => setShowImportDialog(true)}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
@@ -1786,6 +1798,26 @@ export function BoardView() {
}}
/>
{/* Export Features Dialog */}
<ExportFeaturesDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
projectPath={currentProject.path}
features={hookFeatures}
selectedFeatureIds={exportFeatureIds}
/>
{/* Import Features Dialog */}
<ImportFeaturesDialog
open={showImportDialog}
onOpenChange={setShowImportDialog}
projectPath={currentProject.path}
categorySuggestions={persistedCategories}
onImportComplete={() => {
loadFeatures();
}}
/>
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />

View File

@@ -1,29 +1,45 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { ImageIcon, MoreHorizontal, Download, Upload } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onExportFeatures?: () => void;
onImportFeatures?: () => void;
}
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
export function BoardControls({
isMounted,
onShowBoardBackground,
onExportFeatures,
onImportFeatures,
}: BoardControlsProps) {
if (!isMounted) return null;
const buttonClass = cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
);
return (
<TooltipProvider>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onShowBoardBackground}
className={cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
)}
className={buttonClass}
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />
@@ -33,6 +49,32 @@ export function BoardControls({ isMounted, onShowBoardBackground }: BoardControl
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
{/* More Options Menu */}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button className={buttonClass} data-testid="board-more-options-button">
<MoreHorizontal className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>More Options</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={onExportFeatures} data-testid="export-features-menu-item">
<Download className="w-4 h-4 mr-2" />
Export Features
</DropdownMenuItem>
<DropdownMenuItem onClick={onImportFeatures} data-testid="import-features-menu-item">
<Upload className="w-4 h-4 mr-2" />
Import Features
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipProvider>
);

View File

@@ -35,6 +35,8 @@ interface BoardHeaderProps {
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onExportFeatures?: () => void;
onImportFeatures?: () => void;
// View toggle props
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
@@ -60,6 +62,8 @@ export function BoardHeader({
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onExportFeatures,
onImportFeatures,
viewMode,
onViewModeChange,
}: BoardHeaderProps) {
@@ -124,7 +128,12 @@ export function BoardHeader({
currentProjectPath={projectPath}
/>
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
onExportFeatures={onExportFeatures}
onImportFeatures={onImportFeatures}
/>
</div>
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */}

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Download, FileJson, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Feature } from '@/store/app-store';
type ExportFormat = 'json' | 'yaml';
interface ExportFeaturesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
features: Feature[];
selectedFeatureIds?: string[];
}
export function ExportFeaturesDialog({
open,
onOpenChange,
projectPath,
features,
selectedFeatureIds,
}: ExportFeaturesDialogProps) {
const [format, setFormat] = useState<ExportFormat>('json');
const [includeHistory, setIncludeHistory] = useState(true);
const [includePlanSpec, setIncludePlanSpec] = useState(true);
const [isExporting, setIsExporting] = useState(false);
// Determine which features to export
const featuresToExport =
selectedFeatureIds && selectedFeatureIds.length > 0
? features.filter((f) => selectedFeatureIds.includes(f.id))
: features;
// Reset state when dialog opens
useEffect(() => {
if (open) {
setFormat('json');
setIncludeHistory(true);
setIncludePlanSpec(true);
}
}, [open]);
const handleExport = async () => {
setIsExporting(true);
try {
const api = getHttpApiClient();
const result = await api.features.export(projectPath, {
featureIds: selectedFeatureIds,
format,
includeHistory,
includePlanSpec,
prettyPrint: true,
});
if (!result.success || !result.data) {
toast.error(result.error || 'Failed to export features');
return;
}
// Create a blob and trigger download
const mimeType = format === 'json' ? 'application/json' : 'application/x-yaml';
const blob = new Blob([result.data], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = result.filename || `features-export.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Exported ${featuresToExport.length} feature(s) to ${format.toUpperCase()}`);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to export features');
} finally {
setIsExporting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="export-features-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
Export Features
</DialogTitle>
<DialogDescription>
Export {featuresToExport.length} feature(s) to a file for backup or sharing with other
projects.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Format Selection */}
<div className="space-y-2">
<Label>Export Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
<SelectTrigger data-testid="export-format-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
<div className="flex items-center gap-2">
<FileJson className="w-4 h-4" />
<span>JSON</span>
</div>
</SelectItem>
<SelectItem value="yaml">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
<span>YAML</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Options */}
<div className="space-y-3">
<Label>Options</Label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="include-history"
checked={includeHistory}
onCheckedChange={(checked) => setIncludeHistory(!!checked)}
data-testid="export-include-history"
/>
<Label htmlFor="include-history" className="text-sm font-normal cursor-pointer">
Include description history
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-plan-spec"
checked={includePlanSpec}
onCheckedChange={(checked) => setIncludePlanSpec(!!checked)}
data-testid="export-include-plan-spec"
/>
<Label htmlFor="include-plan-spec" className="text-sm font-normal cursor-pointer">
Include plan specifications
</Label>
</div>
</div>
</div>
{/* Features to Export Preview */}
{featuresToExport.length > 0 && featuresToExport.length <= 10 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Features to export</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
{featuresToExport.map((f) => (
<div key={f.id} className="py-1 px-2 truncate text-muted-foreground">
{f.title || f.description.slice(0, 50)}...
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isExporting}>
Cancel
</Button>
<Button onClick={handleExport} disabled={isExporting} data-testid="confirm-export">
<Download className="w-4 h-4 mr-2" />
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,474 @@
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import { Upload, AlertTriangle, CheckCircle2, XCircle, FileJson, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { cn } from '@/lib/utils';
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
interface ImportResult {
success: boolean;
featureId?: string;
importedAt: string;
warnings?: string[];
errors?: string[];
wasOverwritten?: boolean;
}
interface ImportFeaturesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
categorySuggestions: string[];
onImportComplete?: () => void;
}
type ImportStep = 'upload' | 'review' | 'result';
export function ImportFeaturesDialog({
open,
onOpenChange,
projectPath,
categorySuggestions,
onImportComplete,
}: ImportFeaturesDialogProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<ImportStep>('upload');
const [fileData, setFileData] = useState<string>('');
const [fileName, setFileName] = useState<string>('');
const [fileFormat, setFileFormat] = useState<'json' | 'yaml' | null>(null);
// Options
const [overwrite, setOverwrite] = useState(false);
const [targetCategory, setTargetCategory] = useState('');
// Conflict check results
const [conflicts, setConflicts] = useState<ConflictInfo[]>([]);
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false);
// Import results
const [importResults, setImportResults] = useState<ImportResult[]>([]);
const [isImporting, setIsImporting] = useState(false);
// Parse error
const [parseError, setParseError] = useState<string>('');
// Reset state when dialog opens
useEffect(() => {
if (open) {
setStep('upload');
setFileData('');
setFileName('');
setFileFormat(null);
setOverwrite(false);
setTargetCategory('');
setConflicts([]);
setImportResults([]);
setParseError('');
}
}, [open]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Check file extension
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
setParseError('Please select a JSON or YAML file');
return;
}
try {
const content = await file.text();
setFileData(content);
setFileName(file.name);
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
setParseError('');
// Check for conflicts
await checkConflicts(content);
} catch {
setParseError('Failed to read file');
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const checkConflicts = async (data: string) => {
setIsCheckingConflicts(true);
try {
const api = getHttpApiClient();
const result = await api.features.checkConflicts(projectPath, data);
if (!result.success) {
setParseError(result.error || 'Failed to parse import file');
setConflicts([]);
return;
}
setConflicts(result.conflicts || []);
setStep('review');
} catch (error) {
setParseError(error instanceof Error ? error.message : 'Failed to check conflicts');
} finally {
setIsCheckingConflicts(false);
}
};
const handleImport = async () => {
setIsImporting(true);
try {
const api = getHttpApiClient();
const result = await api.features.import(projectPath, fileData, {
overwrite,
targetCategory: targetCategory || undefined,
});
if (!result.success && result.failedCount === result.results?.length) {
toast.error(result.error || 'Failed to import features');
return;
}
setImportResults(result.results || []);
setStep('result');
const successCount = result.importedCount || 0;
const failCount = result.failedCount || 0;
if (failCount === 0) {
toast.success(`Successfully imported ${successCount} feature(s)`);
} else if (successCount > 0) {
toast.warning(`Imported ${successCount} feature(s), ${failCount} failed`);
} else {
toast.error(`Failed to import features`);
}
onImportComplete?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to import features');
} finally {
setIsImporting(false);
}
};
const handleDrop = async (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files[0];
if (!file) return;
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
setParseError('Please drop a JSON or YAML file');
return;
}
try {
const content = await file.text();
setFileData(content);
setFileName(file.name);
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
setParseError('');
await checkConflicts(content);
} catch {
setParseError('Failed to read file');
}
};
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
const conflictingFeatures = conflicts.filter((c) => c.hasConflict);
const hasConflicts = conflictingFeatures.length > 0;
const renderUploadStep = () => (
<div className="py-4 space-y-4">
{/* Drop Zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
'hover:border-primary/50 hover:bg-muted/30',
parseError ? 'border-destructive/50' : 'border-border'
)}
onClick={() => fileInputRef.current?.click()}
data-testid="import-drop-zone"
>
<input
ref={fileInputRef}
type="file"
accept=".json,.yaml,.yml"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex flex-col items-center gap-3">
<Upload className="w-8 h-8 text-muted-foreground" />
<div className="text-sm">
<span className="text-primary font-medium">Click to upload</span>
<span className="text-muted-foreground"> or drag and drop</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileJson className="w-3.5 h-3.5" />
<span>JSON</span>
<span>or</span>
<FileText className="w-3.5 h-3.5" />
<span>YAML</span>
</div>
</div>
</div>
{parseError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
{parseError}
</div>
)}
{isCheckingConflicts && (
<div className="text-sm text-muted-foreground text-center">Analyzing file...</div>
)}
</div>
);
const renderReviewStep = () => (
<div className="py-4 space-y-4">
{/* File Info */}
<div className="flex items-center gap-2 p-3 rounded-md border border-border/50 bg-muted/30">
{fileFormat === 'json' ? (
<FileJson className="w-5 h-5 text-muted-foreground" />
) : (
<FileText className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1 truncate">
<div className="text-sm font-medium">{fileName}</div>
<div className="text-xs text-muted-foreground">
{conflicts.length} feature(s) to import
</div>
</div>
</div>
{/* Conflict Warning */}
{hasConflicts && (
<div className="flex items-start gap-2 p-3 rounded-md border border-warning/50 bg-warning/10">
<AlertTriangle className="w-5 h-5 text-warning shrink-0 mt-0.5" />
<div className="space-y-1">
<div className="text-sm font-medium text-warning">
{conflictingFeatures.length} conflict(s) detected
</div>
<div className="text-xs text-muted-foreground">
The following features already exist in this project:
</div>
<ul className="text-xs text-muted-foreground list-disc list-inside max-h-24 overflow-y-auto">
{conflictingFeatures.map((c) => (
<li key={c.featureId} className="truncate">
{c.existingTitle || c.featureId}
</li>
))}
</ul>
</div>
</div>
)}
{/* Options */}
<div className="space-y-3">
<Label>Import Options</Label>
{hasConflicts && (
<div className="flex items-center gap-2">
<Checkbox
id="overwrite"
checked={overwrite}
onCheckedChange={(checked) => setOverwrite(!!checked)}
data-testid="import-overwrite"
/>
<Label htmlFor="overwrite" className="text-sm font-normal cursor-pointer">
Overwrite existing features with same ID
</Label>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Target Category (optional - override imported categories)
</Label>
<CategoryAutocomplete
value={targetCategory}
onChange={setTargetCategory}
suggestions={categorySuggestions}
placeholder="Keep original categories"
data-testid="import-target-category"
/>
</div>
</div>
{/* Features Preview */}
<div className="space-y-2">
<Label className="text-muted-foreground">Features to import</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
{conflicts.map((c) => (
<div
key={c.featureId}
className={cn(
'py-1 px-2 flex items-center gap-2',
c.hasConflict && !overwrite ? 'text-warning' : 'text-muted-foreground'
)}
>
{c.hasConflict ? (
overwrite ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
) : (
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0" />
)
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
)}
<span className="truncate">{c.title || c.featureId}</span>
{c.hasConflict && !overwrite && (
<span className="text-xs text-warning">(will skip)</span>
)}
</div>
))}
</div>
</div>
</div>
);
const renderResultStep = () => {
const successResults = importResults.filter((r) => r.success);
const failedResults = importResults.filter((r) => !r.success);
return (
<div className="py-4 space-y-4">
{/* Summary */}
<div className="flex items-center gap-4 justify-center">
{successResults.length > 0 && (
<div className="flex items-center gap-2 text-primary">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">{successResults.length} imported</span>
</div>
)}
{failedResults.length > 0 && (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="w-5 h-5" />
<span className="font-medium">{failedResults.length} failed</span>
</div>
)}
</div>
{/* Results List */}
<div className="max-h-60 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm space-y-1">
{importResults.map((result, idx) => (
<div
key={idx}
className={cn(
'py-1.5 px-2 rounded',
result.success ? 'text-foreground' : 'text-destructive bg-destructive/10'
)}
>
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-destructive shrink-0" />
)}
<span className="truncate">{result.featureId || `Feature ${idx + 1}`}</span>
{result.wasOverwritten && (
<span className="text-xs text-muted-foreground">(overwritten)</span>
)}
</div>
{result.warnings && result.warnings.length > 0 && (
<div className="mt-1 pl-5 text-xs text-warning">
{result.warnings.map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="mt-1 pl-5 text-xs text-destructive">
{result.errors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="import-features-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Import Features
</DialogTitle>
<DialogDescription>
{step === 'upload' && 'Import features from a JSON or YAML export file.'}
{step === 'review' && 'Review and configure import options.'}
{step === 'result' && 'Import completed.'}
</DialogDescription>
</DialogHeader>
{step === 'upload' && renderUploadStep()}
{step === 'review' && renderReviewStep()}
{step === 'result' && renderResultStep()}
<DialogFooter>
{step === 'upload' && (
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
)}
{step === 'review' && (
<>
<Button variant="ghost" onClick={() => setStep('upload')}>
Back
</Button>
<Button onClick={handleImport} disabled={isImporting} data-testid="confirm-import">
<Upload className="w-4 h-4 mr-2" />
{isImporting
? 'Importing...'
: `Import ${hasConflicts && !overwrite ? conflicts.filter((c) => !c.hasConflict).length : conflicts.length} Feature(s)`}
</Button>
</>
)}
{step === 'result' && (
<Button onClick={() => onOpenChange(false)} data-testid="close-import">
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,3 +13,5 @@ export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';