mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge pull request #643 from AutoMaker-Org/feature/v0.14.0rc-1768981415660-tt2v
feat: add import / export features in json / yaml format
This commit is contained in:
@@ -10,20 +10,22 @@ interface BoardControlsProps {
|
||||
export function BoardControls({ isMounted, onShowBoardBackground }: 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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Workflow, FlaskConical } from 'lucide-react';
|
||||
import {
|
||||
User,
|
||||
GitBranch,
|
||||
Palette,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
Database,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -14,5 +22,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'testing', label: 'Testing', icon: FlaskConical },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'data', label: 'Data', icon: Database },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Database, Download, Upload } from 'lucide-react';
|
||||
import { ExportFeaturesDialog } from '../board-view/dialogs/export-features-dialog';
|
||||
import { ImportFeaturesDialog } from '../board-view/dialogs/import-features-dialog';
|
||||
import { useBoardFeatures } from '../board-view/hooks';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface DataManagementSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function DataManagementSection({ project }: DataManagementSectionProps) {
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
|
||||
// Fetch features and persisted categories using the existing hook
|
||||
const { features, persistedCategories, loadFeatures } = useBoardFeatures({
|
||||
currentProject: project,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Database className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Data Management
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Export and import features to backup your data or share with other projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Export Section */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Export Features</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Download all features as a JSON or YAML file for backup or sharing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="gap-2"
|
||||
data-testid="export-features-button"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export Features
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* Import Section */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Import Features</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Import features from a previously exported JSON or YAML file.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="gap-2"
|
||||
data-testid="import-features-button"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import Features
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Dialog */}
|
||||
<ExportFeaturesDialog
|
||||
open={showExportDialog}
|
||||
onOpenChange={setShowExportDialog}
|
||||
projectPath={project.path}
|
||||
features={features}
|
||||
/>
|
||||
|
||||
{/* Import Dialog */}
|
||||
<ImportFeaturesDialog
|
||||
open={showImportDialog}
|
||||
onOpenChange={setShowImportDialog}
|
||||
projectPath={project.path}
|
||||
categorySuggestions={persistedCategories}
|
||||
onImportComplete={() => {
|
||||
loadFeatures();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type ProjectSettingsViewId =
|
||||
| 'worktrees'
|
||||
| 'testing'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { TestingSection } from './testing-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
@@ -90,6 +91,8 @@ export function ProjectSettingsView() {
|
||||
return <TestingSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
return <DataManagementSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
|
||||
Reference in New Issue
Block a user