mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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:
@@ -40,7 +40,8 @@
|
|||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"morgan": "1.10.1",
|
"morgan": "1.10.1",
|
||||||
"node-pty": "1.1.0-beta41",
|
"node-pty": "1.1.0-beta41",
|
||||||
"ws": "8.18.3"
|
"ws": "8.18.3",
|
||||||
|
"yaml": "2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie": "0.6.0",
|
"@types/cookie": "0.6.0",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
|||||||
import { createDeleteHandler } from './routes/delete.js';
|
import { createDeleteHandler } from './routes/delete.js';
|
||||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
|
import { createExportHandler } from './routes/export.js';
|
||||||
|
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||||
|
|
||||||
export function createFeaturesRoutes(
|
export function createFeaturesRoutes(
|
||||||
featureLoader: FeatureLoader,
|
featureLoader: FeatureLoader,
|
||||||
@@ -46,6 +48,13 @@ export function createFeaturesRoutes(
|
|||||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||||
router.post('/generate-title', createGenerateTitleHandler(settingsService));
|
router.post('/generate-title', createGenerateTitleHandler(settingsService));
|
||||||
|
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
|
||||||
|
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
|
||||||
|
router.post(
|
||||||
|
'/check-conflicts',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createConflictCheckHandler(featureLoader)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
96
apps/server/src/routes/features/routes/export.ts
Normal file
96
apps/server/src/routes/features/routes/export.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* POST /export endpoint - Export features to JSON or YAML format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import {
|
||||||
|
getFeatureExportService,
|
||||||
|
type ExportFormat,
|
||||||
|
type BulkExportOptions,
|
||||||
|
} from '../../../services/feature-export-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface ExportRequest {
|
||||||
|
projectPath: string;
|
||||||
|
/** Feature IDs to export. If empty/undefined, exports all features */
|
||||||
|
featureIds?: string[];
|
||||||
|
/** Export format: 'json' or 'yaml' */
|
||||||
|
format?: ExportFormat;
|
||||||
|
/** Whether to include description history */
|
||||||
|
includeHistory?: boolean;
|
||||||
|
/** Whether to include plan spec */
|
||||||
|
includePlanSpec?: boolean;
|
||||||
|
/** Filter by category */
|
||||||
|
category?: string;
|
||||||
|
/** Filter by status */
|
||||||
|
status?: string;
|
||||||
|
/** Pretty print output */
|
||||||
|
prettyPrint?: boolean;
|
||||||
|
/** Optional metadata to include */
|
||||||
|
metadata?: {
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
branch?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExportHandler(featureLoader: FeatureLoader) {
|
||||||
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
projectPath,
|
||||||
|
featureIds,
|
||||||
|
format = 'json',
|
||||||
|
includeHistory = true,
|
||||||
|
includePlanSpec = true,
|
||||||
|
category,
|
||||||
|
status,
|
||||||
|
prettyPrint = true,
|
||||||
|
metadata,
|
||||||
|
} = req.body as ExportRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format
|
||||||
|
if (format !== 'json' && format !== 'yaml') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'format must be "json" or "yaml"',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: BulkExportOptions = {
|
||||||
|
format,
|
||||||
|
includeHistory,
|
||||||
|
includePlanSpec,
|
||||||
|
category,
|
||||||
|
status,
|
||||||
|
featureIds,
|
||||||
|
prettyPrint,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportData = await exportService.exportFeatures(projectPath, options);
|
||||||
|
|
||||||
|
// Return the export data as a string in the response
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: exportData,
|
||||||
|
format,
|
||||||
|
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
|
||||||
|
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Export features failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
215
apps/server/src/routes/features/routes/import.ts
Normal file
215
apps/server/src/routes/features/routes/import.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* POST /import endpoint - Import features from JSON or YAML format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
|
||||||
|
import { getFeatureExportService } from '../../../services/feature-export-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface ImportRequest {
|
||||||
|
projectPath: string;
|
||||||
|
/** Raw JSON or YAML string containing feature data */
|
||||||
|
data: string;
|
||||||
|
/** Whether to overwrite existing features with same ID */
|
||||||
|
overwrite?: boolean;
|
||||||
|
/** Whether to preserve branch info from imported features */
|
||||||
|
preserveBranchInfo?: boolean;
|
||||||
|
/** Optional category to assign to all imported features */
|
||||||
|
targetCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConflictCheckRequest {
|
||||||
|
projectPath: string;
|
||||||
|
/** Raw JSON or YAML string containing feature data */
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConflictInfo {
|
||||||
|
featureId: string;
|
||||||
|
title?: string;
|
||||||
|
existingTitle?: string;
|
||||||
|
hasConflict: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImportHandler(featureLoader: FeatureLoader) {
|
||||||
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
projectPath,
|
||||||
|
data,
|
||||||
|
overwrite = false,
|
||||||
|
preserveBranchInfo = false,
|
||||||
|
targetCategory,
|
||||||
|
} = req.body as ImportRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
res.status(400).json({ success: false, error: 'data is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect format and parse the data
|
||||||
|
const format = exportService.detectFormat(data);
|
||||||
|
if (!format) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = exportService.parseImportData(data);
|
||||||
|
if (!parsed) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is a single feature or bulk import
|
||||||
|
const isBulkImport =
|
||||||
|
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
|
||||||
|
|
||||||
|
let results: FeatureImportResult[];
|
||||||
|
|
||||||
|
if (isBulkImport) {
|
||||||
|
// Bulk import
|
||||||
|
results = await exportService.importFeatures(projectPath, data, {
|
||||||
|
overwrite,
|
||||||
|
preserveBranchInfo,
|
||||||
|
targetCategory,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Single feature import - we know it's not a bulk export at this point
|
||||||
|
// It must be either a Feature or FeatureExport
|
||||||
|
const singleData = parsed as Feature | FeatureExport;
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(projectPath, {
|
||||||
|
data: singleData,
|
||||||
|
overwrite,
|
||||||
|
preserveBranchInfo,
|
||||||
|
targetCategory,
|
||||||
|
});
|
||||||
|
results = [result];
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failureCount = results.filter((r) => !r.success).length;
|
||||||
|
const allSuccessful = failureCount === 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: allSuccessful,
|
||||||
|
importedCount: successCount,
|
||||||
|
failedCount: failureCount,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Import features failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for checking conflicts before import
|
||||||
|
*/
|
||||||
|
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
|
||||||
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, data } = req.body as ConflictCheckRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
res.status(400).json({ success: false, error: 'data is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the import data
|
||||||
|
const format = exportService.detectFormat(data);
|
||||||
|
if (!format) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = exportService.parseImportData(data);
|
||||||
|
if (!parsed) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to parse import data.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract features from the data
|
||||||
|
type FeatureExportType = { feature: { id: string; title?: string } };
|
||||||
|
type BulkExportType = { features: FeatureExportType[] };
|
||||||
|
type RawFeatureType = { id: string; title?: string };
|
||||||
|
|
||||||
|
let featuresToCheck: Array<{ id: string; title?: string }> = [];
|
||||||
|
|
||||||
|
if ('features' in parsed && Array.isArray((parsed as BulkExportType).features)) {
|
||||||
|
// Bulk export format
|
||||||
|
featuresToCheck = (parsed as BulkExportType).features.map((f) => ({
|
||||||
|
id: f.feature.id,
|
||||||
|
title: f.feature.title,
|
||||||
|
}));
|
||||||
|
} else if ('feature' in parsed) {
|
||||||
|
// Single FeatureExport format
|
||||||
|
const featureExport = parsed as FeatureExportType;
|
||||||
|
featuresToCheck = [
|
||||||
|
{
|
||||||
|
id: featureExport.feature.id,
|
||||||
|
title: featureExport.feature.title,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if ('id' in parsed) {
|
||||||
|
// Raw Feature format
|
||||||
|
const rawFeature = parsed as RawFeatureType;
|
||||||
|
featuresToCheck = [{ id: rawFeature.id, title: rawFeature.title }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each feature for conflicts
|
||||||
|
const conflicts: ConflictInfo[] = [];
|
||||||
|
for (const feature of featuresToCheck) {
|
||||||
|
const existing = await featureLoader.get(projectPath, feature.id);
|
||||||
|
conflicts.push({
|
||||||
|
featureId: feature.id,
|
||||||
|
title: feature.title,
|
||||||
|
existingTitle: existing?.title,
|
||||||
|
hasConflict: !!existing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasConflicts = conflicts.some((c) => c.hasConflict);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
hasConflicts,
|
||||||
|
conflicts,
|
||||||
|
totalFeatures: featuresToCheck.length,
|
||||||
|
conflictCount: conflicts.filter((c) => c.hasConflict).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Conflict check failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
521
apps/server/src/services/feature-export-service.ts
Normal file
521
apps/server/src/services/feature-export-service.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Feature Export Service - Handles exporting and importing features in JSON/YAML formats
|
||||||
|
*
|
||||||
|
* Provides functionality to:
|
||||||
|
* - Export single features to JSON or YAML format
|
||||||
|
* - Export multiple features (bulk export)
|
||||||
|
* - Import features from JSON or YAML data
|
||||||
|
* - Validate import data for compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
||||||
|
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
|
||||||
|
import { FeatureLoader } from './feature-loader.js';
|
||||||
|
|
||||||
|
const logger = createLogger('FeatureExportService');
|
||||||
|
|
||||||
|
/** Current export format version */
|
||||||
|
export const FEATURE_EXPORT_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
/** Supported export formats */
|
||||||
|
export type ExportFormat = 'json' | 'yaml';
|
||||||
|
|
||||||
|
/** Options for exporting features */
|
||||||
|
export interface ExportOptions {
|
||||||
|
/** Format to export in (default: 'json') */
|
||||||
|
format?: ExportFormat;
|
||||||
|
/** Whether to include description history (default: true) */
|
||||||
|
includeHistory?: boolean;
|
||||||
|
/** Whether to include plan spec (default: true) */
|
||||||
|
includePlanSpec?: boolean;
|
||||||
|
/** Optional metadata to include */
|
||||||
|
metadata?: {
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
branch?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
/** Who/what is performing the export */
|
||||||
|
exportedBy?: string;
|
||||||
|
/** Pretty print output (default: true) */
|
||||||
|
prettyPrint?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for bulk export */
|
||||||
|
export interface BulkExportOptions extends ExportOptions {
|
||||||
|
/** Filter by category */
|
||||||
|
category?: string;
|
||||||
|
/** Filter by status */
|
||||||
|
status?: string;
|
||||||
|
/** Feature IDs to include (if not specified, exports all) */
|
||||||
|
featureIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a bulk export */
|
||||||
|
export interface BulkExportResult {
|
||||||
|
/** Export format version */
|
||||||
|
version: string;
|
||||||
|
/** ISO date string when the export was created */
|
||||||
|
exportedAt: string;
|
||||||
|
/** Number of features exported */
|
||||||
|
count: number;
|
||||||
|
/** The exported features */
|
||||||
|
features: FeatureExport[];
|
||||||
|
/** Export metadata */
|
||||||
|
metadata?: {
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
branch?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeatureExportService - Manages feature export and import operations
|
||||||
|
*/
|
||||||
|
export class FeatureExportService {
|
||||||
|
private featureLoader: FeatureLoader;
|
||||||
|
|
||||||
|
constructor(featureLoader?: FeatureLoader) {
|
||||||
|
this.featureLoader = featureLoader || new FeatureLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a single feature to the specified format
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param featureId - ID of the feature to export
|
||||||
|
* @param options - Export options
|
||||||
|
* @returns Promise resolving to the exported feature string
|
||||||
|
*/
|
||||||
|
async exportFeature(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
options: ExportOptions = {}
|
||||||
|
): Promise<string> {
|
||||||
|
const feature = await this.featureLoader.get(projectPath, featureId);
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${featureId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.exportFeatureData(feature, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export feature data to the specified format (without fetching from disk)
|
||||||
|
*
|
||||||
|
* @param feature - The feature to export
|
||||||
|
* @param options - Export options
|
||||||
|
* @returns The exported feature string
|
||||||
|
*/
|
||||||
|
exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
|
||||||
|
const {
|
||||||
|
format = 'json',
|
||||||
|
includeHistory = true,
|
||||||
|
includePlanSpec = true,
|
||||||
|
metadata,
|
||||||
|
exportedBy,
|
||||||
|
prettyPrint = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Prepare feature data, optionally excluding some fields
|
||||||
|
const featureData = this.prepareFeatureForExport(feature, {
|
||||||
|
includeHistory,
|
||||||
|
includePlanSpec,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData: FeatureExport = {
|
||||||
|
version: FEATURE_EXPORT_VERSION,
|
||||||
|
feature: featureData,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
...(exportedBy ? { exportedBy } : {}),
|
||||||
|
...(metadata ? { metadata } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.serializeExport(exportData, format, prettyPrint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export multiple features to the specified format
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param options - Bulk export options
|
||||||
|
* @returns Promise resolving to the exported features string
|
||||||
|
*/
|
||||||
|
async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
|
||||||
|
const {
|
||||||
|
format = 'json',
|
||||||
|
category,
|
||||||
|
status,
|
||||||
|
featureIds,
|
||||||
|
includeHistory = true,
|
||||||
|
includePlanSpec = true,
|
||||||
|
metadata,
|
||||||
|
prettyPrint = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Get all features
|
||||||
|
let features = await this.featureLoader.getAll(projectPath);
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (featureIds && featureIds.length > 0) {
|
||||||
|
const idSet = new Set(featureIds);
|
||||||
|
features = features.filter((f) => idSet.has(f.id));
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
features = features.filter((f) => f.category === category);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
features = features.filter((f) => f.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare feature exports
|
||||||
|
const featureExports: FeatureExport[] = features.map((feature) => ({
|
||||||
|
version: FEATURE_EXPORT_VERSION,
|
||||||
|
feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bulkExport: BulkExportResult = {
|
||||||
|
version: FEATURE_EXPORT_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: featureExports.length,
|
||||||
|
features: featureExports,
|
||||||
|
...(metadata ? { metadata } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Exported ${featureExports.length} features from ${projectPath}`);
|
||||||
|
|
||||||
|
return this.serializeBulkExport(bulkExport, format, prettyPrint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a feature from JSON or YAML data
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param importData - Import configuration
|
||||||
|
* @returns Promise resolving to the import result
|
||||||
|
*/
|
||||||
|
async importFeature(
|
||||||
|
projectPath: string,
|
||||||
|
importData: FeatureImport
|
||||||
|
): Promise<FeatureImportResult> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
|
||||||
|
const feature = this.extractFeatureFromImport(importData.data);
|
||||||
|
if (!feature) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
errors: ['Invalid import data: could not extract feature'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const validationErrors = this.validateFeature(feature);
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
errors: validationErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the feature ID to use
|
||||||
|
const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();
|
||||||
|
|
||||||
|
// Check for existing feature
|
||||||
|
const existingFeature = await this.featureLoader.get(projectPath, featureId);
|
||||||
|
if (existingFeature && !importData.overwrite) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare feature for import
|
||||||
|
const featureToImport: Feature = {
|
||||||
|
...feature,
|
||||||
|
id: featureId,
|
||||||
|
// Optionally override category
|
||||||
|
...(importData.targetCategory ? { category: importData.targetCategory } : {}),
|
||||||
|
// Clear branch info if not preserving
|
||||||
|
...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear runtime-specific fields that shouldn't be imported
|
||||||
|
delete featureToImport.titleGenerating;
|
||||||
|
delete featureToImport.error;
|
||||||
|
|
||||||
|
// Handle image paths - they won't be valid after import
|
||||||
|
if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
|
||||||
|
);
|
||||||
|
featureToImport.imagePaths = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text file paths - they won't be valid after import
|
||||||
|
if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
|
||||||
|
);
|
||||||
|
featureToImport.textFilePaths = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update the feature
|
||||||
|
if (existingFeature) {
|
||||||
|
await this.featureLoader.update(projectPath, featureId, featureToImport);
|
||||||
|
logger.info(`Updated feature ${featureId} via import`);
|
||||||
|
} else {
|
||||||
|
await this.featureLoader.create(projectPath, featureToImport);
|
||||||
|
logger.info(`Created feature ${featureId} via import`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
featureId,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
|
wasOverwritten: !!existingFeature,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to import feature:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import multiple features from JSON or YAML data
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param data - Raw JSON or YAML string, or parsed data
|
||||||
|
* @param options - Import options applied to all features
|
||||||
|
* @returns Promise resolving to array of import results
|
||||||
|
*/
|
||||||
|
async importFeatures(
|
||||||
|
projectPath: string,
|
||||||
|
data: string | BulkExportResult,
|
||||||
|
options: Omit<FeatureImport, 'data'> = {}
|
||||||
|
): Promise<FeatureImportResult[]> {
|
||||||
|
let bulkData: BulkExportResult;
|
||||||
|
|
||||||
|
// Parse if string
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const parsed = this.parseImportData(data);
|
||||||
|
if (!parsed || !this.isBulkExport(parsed)) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
errors: ['Invalid bulk import data: expected BulkExportResult format'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
bulkData = parsed as BulkExportResult;
|
||||||
|
} else {
|
||||||
|
bulkData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each feature
|
||||||
|
const results: FeatureImportResult[] = [];
|
||||||
|
for (const featureExport of bulkData.features) {
|
||||||
|
const result = await this.importFeature(projectPath, {
|
||||||
|
data: featureExport,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse import data from JSON or YAML string
|
||||||
|
*
|
||||||
|
* @param data - Raw JSON or YAML string
|
||||||
|
* @returns Parsed data or null if parsing fails
|
||||||
|
*/
|
||||||
|
parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
|
||||||
|
const trimmed = data.trim();
|
||||||
|
|
||||||
|
// Try JSON first
|
||||||
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
// Fall through to YAML
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try YAML
|
||||||
|
try {
|
||||||
|
return yamlParse(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse import data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the format of import data
|
||||||
|
*
|
||||||
|
* @param data - Raw string data
|
||||||
|
* @returns Detected format or null if unknown
|
||||||
|
*/
|
||||||
|
detectFormat(data: string): ExportFormat | null {
|
||||||
|
const trimmed = data.trim();
|
||||||
|
|
||||||
|
// JSON detection
|
||||||
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
JSON.parse(trimmed);
|
||||||
|
return 'json';
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML detection (if it parses and wasn't JSON)
|
||||||
|
try {
|
||||||
|
yamlParse(trimmed);
|
||||||
|
return 'yaml';
|
||||||
|
} catch {
|
||||||
|
// Not valid YAML either
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare a feature for export by optionally removing fields
|
||||||
|
*/
|
||||||
|
private prepareFeatureForExport(
|
||||||
|
feature: Feature,
|
||||||
|
options: { includeHistory?: boolean; includePlanSpec?: boolean }
|
||||||
|
): Feature {
|
||||||
|
const { includeHistory = true, includePlanSpec = true } = options;
|
||||||
|
|
||||||
|
// Clone to avoid modifying original
|
||||||
|
const exported: Feature = { ...feature };
|
||||||
|
|
||||||
|
// Remove transient fields that shouldn't be exported
|
||||||
|
delete exported.titleGenerating;
|
||||||
|
delete exported.error;
|
||||||
|
|
||||||
|
// Optionally exclude history
|
||||||
|
if (!includeHistory) {
|
||||||
|
delete exported.descriptionHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally exclude plan spec
|
||||||
|
if (!includePlanSpec) {
|
||||||
|
delete exported.planSpec;
|
||||||
|
}
|
||||||
|
|
||||||
|
return exported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a Feature from import data (handles both raw and wrapped formats)
|
||||||
|
*/
|
||||||
|
private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a FeatureExport wrapper
|
||||||
|
if ('version' in data && 'feature' in data && 'exportedAt' in data) {
|
||||||
|
const exportData = data as FeatureExport;
|
||||||
|
return exportData.feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume it's a raw Feature
|
||||||
|
return data as Feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if parsed data is a bulk export
|
||||||
|
*/
|
||||||
|
private isBulkExport(data: unknown): data is BulkExportResult {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a feature has required fields
|
||||||
|
*/
|
||||||
|
private validateFeature(feature: Feature): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!feature.description && !feature.title) {
|
||||||
|
errors.push('Feature must have at least a title or description');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feature.category) {
|
||||||
|
errors.push('Feature must have a category');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize export data to string
|
||||||
|
*/
|
||||||
|
private serializeExport(data: FeatureExport, format: ExportFormat, prettyPrint: boolean): string {
|
||||||
|
if (format === 'yaml') {
|
||||||
|
return yamlStringify(data, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: 120,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize bulk export data to string
|
||||||
|
*/
|
||||||
|
private serializeBulkExport(
|
||||||
|
data: BulkExportResult,
|
||||||
|
format: ExportFormat,
|
||||||
|
prettyPrint: boolean
|
||||||
|
): string {
|
||||||
|
if (format === 'yaml') {
|
||||||
|
return yamlStringify(data, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: 120,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let featureExportServiceInstance: FeatureExportService | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton feature export service instance
|
||||||
|
*/
|
||||||
|
export function getFeatureExportService(): FeatureExportService {
|
||||||
|
if (!featureExportServiceInstance) {
|
||||||
|
featureExportServiceInstance = new FeatureExportService();
|
||||||
|
}
|
||||||
|
return featureExportServiceInstance;
|
||||||
|
}
|
||||||
623
apps/server/tests/unit/services/feature-export-service.test.ts
Normal file
623
apps/server/tests/unit/services/feature-export-service.test.ts
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { FeatureExportService, FEATURE_EXPORT_VERSION } from '@/services/feature-export-service.js';
|
||||||
|
import type { Feature, FeatureExport } from '@automaker/types';
|
||||||
|
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||||
|
|
||||||
|
describe('feature-export-service.ts', () => {
|
||||||
|
let exportService: FeatureExportService;
|
||||||
|
let mockFeatureLoader: {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
getAll: ReturnType<typeof vi.fn>;
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
update: ReturnType<typeof vi.fn>;
|
||||||
|
generateFeatureId: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
const testProjectPath = '/test/project';
|
||||||
|
|
||||||
|
const sampleFeature: Feature = {
|
||||||
|
id: 'feature-123-abc',
|
||||||
|
title: 'Test Feature',
|
||||||
|
category: 'UI',
|
||||||
|
description: 'A test feature description',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 1,
|
||||||
|
dependencies: ['feature-456'],
|
||||||
|
descriptionHistory: [
|
||||||
|
{
|
||||||
|
description: 'Initial description',
|
||||||
|
timestamp: '2024-01-01T00:00:00.000Z',
|
||||||
|
source: 'initial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
planSpec: {
|
||||||
|
status: 'generated',
|
||||||
|
content: 'Plan content',
|
||||||
|
version: 1,
|
||||||
|
reviewedByUser: false,
|
||||||
|
},
|
||||||
|
imagePaths: ['/tmp/image1.png', '/tmp/image2.jpg'],
|
||||||
|
textFilePaths: [
|
||||||
|
{
|
||||||
|
id: 'file-1',
|
||||||
|
path: '/tmp/doc.txt',
|
||||||
|
filename: 'doc.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
content: 'Some content',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Create mock FeatureLoader instance
|
||||||
|
mockFeatureLoader = {
|
||||||
|
get: vi.fn(),
|
||||||
|
getAll: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
generateFeatureId: vi.fn().mockReturnValue('feature-mock-id'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject mock via constructor
|
||||||
|
exportService = new FeatureExportService(mockFeatureLoader as unknown as FeatureLoader);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportFeatureData', () => {
|
||||||
|
it('should export feature to JSON format', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.version).toBe(FEATURE_EXPORT_VERSION);
|
||||||
|
expect(parsed.feature.id).toBe(sampleFeature.id);
|
||||||
|
expect(parsed.feature.title).toBe(sampleFeature.title);
|
||||||
|
expect(parsed.exportedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export feature to YAML format', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, { format: 'yaml' });
|
||||||
|
|
||||||
|
expect(result).toContain('version:');
|
||||||
|
expect(result).toContain('feature:');
|
||||||
|
expect(result).toContain('Test Feature');
|
||||||
|
expect(result).toContain('exportedAt:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude description history when option is false', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, {
|
||||||
|
format: 'json',
|
||||||
|
includeHistory: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.feature.descriptionHistory).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include description history by default', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.feature.descriptionHistory).toBeDefined();
|
||||||
|
expect(parsed.feature.descriptionHistory).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude plan spec when option is false', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, {
|
||||||
|
format: 'json',
|
||||||
|
includePlanSpec: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.feature.planSpec).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include plan spec by default', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.feature.planSpec).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include metadata when provided', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, {
|
||||||
|
format: 'json',
|
||||||
|
metadata: { projectName: 'TestProject', branch: 'main' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.metadata).toEqual({ projectName: 'TestProject', branch: 'main' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include exportedBy when provided', () => {
|
||||||
|
const result = exportService.exportFeatureData(sampleFeature, {
|
||||||
|
format: 'json',
|
||||||
|
exportedBy: 'test-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.exportedBy).toBe('test-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove transient fields (titleGenerating, error)', () => {
|
||||||
|
const featureWithTransient: Feature = {
|
||||||
|
...sampleFeature,
|
||||||
|
titleGenerating: true,
|
||||||
|
error: 'Some error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = exportService.exportFeatureData(featureWithTransient, { format: 'json' });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.feature.titleGenerating).toBeUndefined();
|
||||||
|
expect(parsed.feature.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support compact JSON (prettyPrint: false)', () => {
|
||||||
|
const prettyResult = exportService.exportFeatureData(sampleFeature, {
|
||||||
|
format: 'json',
|
||||||
|
prettyPrint: true,
|
||||||
|
});
|
||||||
|
const compactResult = exportService.exportFeatureData(sampleFeature, {
|
||||||
|
format: 'json',
|
||||||
|
prettyPrint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compact should have no newlines/indentation
|
||||||
|
expect(compactResult).not.toContain('\n');
|
||||||
|
// Pretty should have newlines
|
||||||
|
expect(prettyResult).toContain('\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportFeature', () => {
|
||||||
|
it('should fetch and export feature by ID', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeature(testProjectPath, 'feature-123-abc');
|
||||||
|
|
||||||
|
expect(mockFeatureLoader.get).toHaveBeenCalledWith(testProjectPath, 'feature-123-abc');
|
||||||
|
const parsed = JSON.parse(result) as FeatureExport;
|
||||||
|
expect(parsed.feature.id).toBe(sampleFeature.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when feature not found', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(exportService.exportFeature(testProjectPath, 'nonexistent')).rejects.toThrow(
|
||||||
|
'Feature nonexistent not found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportFeatures', () => {
|
||||||
|
const features: Feature[] = [
|
||||||
|
{ ...sampleFeature, id: 'feature-1', category: 'UI' },
|
||||||
|
{ ...sampleFeature, id: 'feature-2', category: 'Backend', status: 'completed' },
|
||||||
|
{ ...sampleFeature, id: 'feature-3', category: 'UI', status: 'pending' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should export all features', async () => {
|
||||||
|
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeatures(testProjectPath);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.count).toBe(3);
|
||||||
|
expect(parsed.features).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by category', async () => {
|
||||||
|
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeatures(testProjectPath, { category: 'UI' });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.count).toBe(2);
|
||||||
|
expect(parsed.features.every((f: FeatureExport) => f.feature.category === 'UI')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeatures(testProjectPath, { status: 'completed' });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.count).toBe(1);
|
||||||
|
expect(parsed.features[0].feature.status).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by feature IDs', async () => {
|
||||||
|
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeatures(testProjectPath, {
|
||||||
|
featureIds: ['feature-1', 'feature-3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.count).toBe(2);
|
||||||
|
const ids = parsed.features.map((f: FeatureExport) => f.feature.id);
|
||||||
|
expect(ids).toContain('feature-1');
|
||||||
|
expect(ids).toContain('feature-3');
|
||||||
|
expect(ids).not.toContain('feature-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export to YAML format', async () => {
|
||||||
|
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeatures(testProjectPath, { format: 'yaml' });
|
||||||
|
|
||||||
|
expect(result).toContain('version:');
|
||||||
|
expect(result).toContain('count:');
|
||||||
|
expect(result).toContain('features:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include metadata when provided', async () => {
|
||||||
|
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||||
|
|
||||||
|
const result = await exportService.exportFeatures(testProjectPath, {
|
||||||
|
metadata: { projectName: 'TestProject' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.metadata).toEqual({ projectName: 'TestProject' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseImportData', () => {
|
||||||
|
it('should parse valid JSON', () => {
|
||||||
|
const json = JSON.stringify(sampleFeature);
|
||||||
|
const result = exportService.parseImportData(json);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect((result as Feature).id).toBe(sampleFeature.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse valid YAML', () => {
|
||||||
|
const yaml = `
|
||||||
|
id: feature-yaml-123
|
||||||
|
title: YAML Feature
|
||||||
|
category: Testing
|
||||||
|
description: A YAML feature
|
||||||
|
`;
|
||||||
|
const result = exportService.parseImportData(yaml);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect((result as Feature).id).toBe('feature-yaml-123');
|
||||||
|
expect((result as Feature).title).toBe('YAML Feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid data', () => {
|
||||||
|
const result = exportService.parseImportData('not valid {json} or yaml: [');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse FeatureExport wrapper', () => {
|
||||||
|
const exportData: FeatureExport = {
|
||||||
|
version: '1.0.0',
|
||||||
|
feature: sampleFeature,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(exportData);
|
||||||
|
|
||||||
|
const result = exportService.parseImportData(json) as FeatureExport;
|
||||||
|
|
||||||
|
expect(result.version).toBe('1.0.0');
|
||||||
|
expect(result.feature.id).toBe(sampleFeature.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectFormat', () => {
|
||||||
|
it('should detect JSON format', () => {
|
||||||
|
const json = JSON.stringify({ id: 'test' });
|
||||||
|
expect(exportService.detectFormat(json)).toBe('json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect YAML format', () => {
|
||||||
|
const yaml = `
|
||||||
|
id: test
|
||||||
|
title: Test
|
||||||
|
`;
|
||||||
|
expect(exportService.detectFormat(yaml)).toBe('yaml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect YAML for plain text (YAML is very permissive)', () => {
|
||||||
|
// YAML parses any plain text as a string, so this is detected as valid YAML
|
||||||
|
// The actual validation happens in parseImportData which checks for required fields
|
||||||
|
expect(exportService.detectFormat('not valid {[')).toBe('yaml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace', () => {
|
||||||
|
const json = ' { "id": "test" } ';
|
||||||
|
expect(exportService.detectFormat(json)).toBe('json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importFeature', () => {
|
||||||
|
it('should import feature from raw Feature data', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.featureId).toBe(sampleFeature.id);
|
||||||
|
expect(mockFeatureLoader.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import feature from FeatureExport wrapper', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const exportData: FeatureExport = {
|
||||||
|
version: '1.0.0',
|
||||||
|
feature: sampleFeature,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: exportData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.featureId).toBe(sampleFeature.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom ID when provided', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...sampleFeature,
|
||||||
|
id: data.id!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
newId: 'custom-id-123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.featureId).toBe('custom-id-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when feature exists and overwrite is false', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
overwrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors).toContain(
|
||||||
|
`Feature with ID ${sampleFeature.id} already exists. Set overwrite: true to replace.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite when overwrite is true', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||||
|
mockFeatureLoader.update.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.wasOverwritten).toBe(true);
|
||||||
|
expect(mockFeatureLoader.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply target category override', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...sampleFeature,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
targetCategory: 'NewCategory',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||||
|
expect(createCall[1].category).toBe('NewCategory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear branch info when preserveBranchInfo is false', async () => {
|
||||||
|
const featureWithBranch: Feature = {
|
||||||
|
...sampleFeature,
|
||||||
|
branchName: 'feature/test-branch',
|
||||||
|
};
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...featureWithBranch,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await exportService.importFeature(testProjectPath, {
|
||||||
|
data: featureWithBranch,
|
||||||
|
preserveBranchInfo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||||
|
expect(createCall[1].branchName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve branch info when preserveBranchInfo is true', async () => {
|
||||||
|
const featureWithBranch: Feature = {
|
||||||
|
...sampleFeature,
|
||||||
|
branchName: 'feature/test-branch',
|
||||||
|
};
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...featureWithBranch,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await exportService.importFeature(testProjectPath, {
|
||||||
|
data: featureWithBranch,
|
||||||
|
preserveBranchInfo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||||
|
expect(createCall[1].branchName).toBe('feature/test-branch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn and clear image paths', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.warnings).toBeDefined();
|
||||||
|
expect(result.warnings).toContainEqual(expect.stringContaining('image path'));
|
||||||
|
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||||
|
expect(createCall[1].imagePaths).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn and clear text file paths', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: sampleFeature,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.warnings).toBeDefined();
|
||||||
|
expect(result.warnings).toContainEqual(expect.stringContaining('text file path'));
|
||||||
|
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||||
|
expect(createCall[1].textFilePaths).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with validation error for missing required fields', async () => {
|
||||||
|
const invalidFeature = {
|
||||||
|
id: 'feature-invalid',
|
||||||
|
// Missing description, title, and category
|
||||||
|
} as Feature;
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: invalidFeature,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors).toBeDefined();
|
||||||
|
expect(result.errors!.some((e) => e.includes('title or description'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate ID when none provided', async () => {
|
||||||
|
const featureWithoutId = {
|
||||||
|
title: 'No ID Feature',
|
||||||
|
category: 'Testing',
|
||||||
|
description: 'Feature without ID',
|
||||||
|
} as Feature;
|
||||||
|
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...featureWithoutId,
|
||||||
|
id: data.id!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await exportService.importFeature(testProjectPath, {
|
||||||
|
data: featureWithoutId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.featureId).toBe('feature-mock-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importFeatures', () => {
|
||||||
|
const bulkExport = {
|
||||||
|
version: '1.0.0',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: 2,
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
version: '1.0.0',
|
||||||
|
feature: { ...sampleFeature, id: 'feature-1' },
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '1.0.0',
|
||||||
|
feature: { ...sampleFeature, id: 'feature-2' },
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should import multiple features from JSON string', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...sampleFeature,
|
||||||
|
id: data.id!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = await exportService.importFeatures(
|
||||||
|
testProjectPath,
|
||||||
|
JSON.stringify(bulkExport)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0].success).toBe(true);
|
||||||
|
expect(results[1].success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import multiple features from parsed data', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...sampleFeature,
|
||||||
|
id: data.id!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = await exportService.importFeatures(testProjectPath, bulkExport);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.every((r) => r.success)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply options to all features', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValue(null);
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...sampleFeature,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await exportService.importFeatures(testProjectPath, bulkExport, {
|
||||||
|
targetCategory: 'ImportedCategory',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCalls = mockFeatureLoader.create.mock.calls;
|
||||||
|
expect(createCalls[0][1].category).toBe('ImportedCategory');
|
||||||
|
expect(createCalls[1][1].category).toBe('ImportedCategory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for invalid bulk format', async () => {
|
||||||
|
const results = await exportService.importFeatures(testProjectPath, '{ "invalid": "data" }');
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].success).toBe(false);
|
||||||
|
expect(results[0].errors).toContainEqual(expect.stringContaining('Invalid bulk import data'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial failures', async () => {
|
||||||
|
mockFeatureLoader.get.mockResolvedValueOnce(null).mockResolvedValueOnce(sampleFeature); // Second feature exists
|
||||||
|
|
||||||
|
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||||
|
...sampleFeature,
|
||||||
|
id: data.id!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = await exportService.importFeatures(testProjectPath, bulkExport, {
|
||||||
|
overwrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0].success).toBe(true);
|
||||||
|
expect(results[1].success).toBe(false); // Exists without overwrite
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,8 @@ import {
|
|||||||
FollowUpDialog,
|
FollowUpDialog,
|
||||||
PlanApprovalDialog,
|
PlanApprovalDialog,
|
||||||
PullResolveConflictsDialog,
|
PullResolveConflictsDialog,
|
||||||
|
ExportFeaturesDialog,
|
||||||
|
ImportFeaturesDialog,
|
||||||
} from './board-view/dialogs';
|
} from './board-view/dialogs';
|
||||||
import type { DependencyLinkType } from './board-view/dialogs';
|
import type { DependencyLinkType } from './board-view/dialogs';
|
||||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||||
@@ -234,6 +236,11 @@ export function BoardView() {
|
|||||||
} = useSelectionMode();
|
} = useSelectionMode();
|
||||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
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)
|
// View mode state (kanban vs list)
|
||||||
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
|
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
|
||||||
|
|
||||||
@@ -1309,6 +1316,11 @@ export function BoardView() {
|
|||||||
isCreatingSpec={isCreatingSpec}
|
isCreatingSpec={isCreatingSpec}
|
||||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
|
onExportFeatures={() => {
|
||||||
|
setExportFeatureIds(undefined); // Export all features
|
||||||
|
setShowExportDialog(true);
|
||||||
|
}}
|
||||||
|
onImportFeatures={() => setShowImportDialog(true)}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
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 */}
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
{getShowInitScriptIndicator(currentProject.path) && (
|
{getShowInitScriptIndicator(currentProject.path) && (
|
||||||
<InitScriptIndicator projectPath={currentProject.path} />
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface BoardControlsProps {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
|
onExportFeatures?: () => void;
|
||||||
|
onImportFeatures?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
|
export function BoardControls({
|
||||||
|
isMounted,
|
||||||
|
onShowBoardBackground,
|
||||||
|
onExportFeatures,
|
||||||
|
onImportFeatures,
|
||||||
|
}: BoardControlsProps) {
|
||||||
if (!isMounted) return null;
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-2">
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={onShowBoardBackground}
|
onClick={onShowBoardBackground}
|
||||||
className={cn(
|
className={buttonClass}
|
||||||
'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'
|
|
||||||
)}
|
|
||||||
data-testid="board-background-button"
|
data-testid="board-background-button"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-4 h-4" />
|
<ImageIcon className="w-4 h-4" />
|
||||||
@@ -33,6 +49,32 @@ export function BoardControls({ isMounted, onShowBoardBackground }: BoardControl
|
|||||||
<p>Board Background Settings</p>
|
<p>Board Background Settings</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ interface BoardHeaderProps {
|
|||||||
creatingSpecProjectPath?: string;
|
creatingSpecProjectPath?: string;
|
||||||
// Board controls props
|
// Board controls props
|
||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
|
onExportFeatures?: () => void;
|
||||||
|
onImportFeatures?: () => void;
|
||||||
// View toggle props
|
// View toggle props
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
onViewModeChange: (mode: ViewMode) => void;
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
@@ -60,6 +62,8 @@ export function BoardHeader({
|
|||||||
isCreatingSpec,
|
isCreatingSpec,
|
||||||
creatingSpecProjectPath,
|
creatingSpecProjectPath,
|
||||||
onShowBoardBackground,
|
onShowBoardBackground,
|
||||||
|
onExportFeatures,
|
||||||
|
onImportFeatures,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
@@ -124,7 +128,12 @@ export function BoardHeader({
|
|||||||
currentProjectPath={projectPath}
|
currentProjectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
|
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
|
||||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
<BoardControls
|
||||||
|
isMounted={isMounted}
|
||||||
|
onShowBoardBackground={onShowBoardBackground}
|
||||||
|
onExportFeatures={onExportFeatures}
|
||||||
|
onImportFeatures={onImportFeatures}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||||
|
|||||||
@@ -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 { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
|
||||||
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||||
|
export { ExportFeaturesDialog } from './export-features-dialog';
|
||||||
|
export { ImportFeaturesDialog } from './import-features-dialog';
|
||||||
|
|||||||
@@ -1631,6 +1631,64 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
results?: Array<{ featureId: string; success: boolean; error?: string }>;
|
results?: Array<{ featureId: string; success: boolean; error?: string }>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
export: (
|
||||||
|
projectPath: string,
|
||||||
|
options?: {
|
||||||
|
featureIds?: string[];
|
||||||
|
format?: 'json' | 'yaml';
|
||||||
|
includeHistory?: boolean;
|
||||||
|
includePlanSpec?: boolean;
|
||||||
|
category?: string;
|
||||||
|
status?: string;
|
||||||
|
prettyPrint?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: string;
|
||||||
|
format?: 'json' | 'yaml';
|
||||||
|
contentType?: string;
|
||||||
|
filename?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
import: (
|
||||||
|
projectPath: string,
|
||||||
|
data: string,
|
||||||
|
options?: {
|
||||||
|
overwrite?: boolean;
|
||||||
|
preserveBranchInfo?: boolean;
|
||||||
|
targetCategory?: string;
|
||||||
|
}
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
importedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
results?: Array<{
|
||||||
|
success: boolean;
|
||||||
|
featureId?: string;
|
||||||
|
importedAt: string;
|
||||||
|
warnings?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
wasOverwritten?: boolean;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
checkConflicts: (
|
||||||
|
projectPath: string,
|
||||||
|
data: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
conflicts?: Array<{
|
||||||
|
featureId: string;
|
||||||
|
title?: string;
|
||||||
|
existingTitle?: string;
|
||||||
|
hasConflict: boolean;
|
||||||
|
}>;
|
||||||
|
totalFeatures?: number;
|
||||||
|
conflictCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
} = {
|
} = {
|
||||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||||
get: (projectPath: string, featureId: string) =>
|
get: (projectPath: string, featureId: string) =>
|
||||||
@@ -1663,6 +1721,64 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||||
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
||||||
this.post('/api/features/bulk-delete', { projectPath, featureIds }),
|
this.post('/api/features/bulk-delete', { projectPath, featureIds }),
|
||||||
|
export: (
|
||||||
|
projectPath: string,
|
||||||
|
options?: {
|
||||||
|
featureIds?: string[];
|
||||||
|
format?: 'json' | 'yaml';
|
||||||
|
includeHistory?: boolean;
|
||||||
|
includePlanSpec?: boolean;
|
||||||
|
category?: string;
|
||||||
|
status?: string;
|
||||||
|
prettyPrint?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: string;
|
||||||
|
format?: 'json' | 'yaml';
|
||||||
|
contentType?: string;
|
||||||
|
filename?: string;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/features/export', { projectPath, ...options }),
|
||||||
|
import: (
|
||||||
|
projectPath: string,
|
||||||
|
data: string,
|
||||||
|
options?: {
|
||||||
|
overwrite?: boolean;
|
||||||
|
preserveBranchInfo?: boolean;
|
||||||
|
targetCategory?: string;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
importedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
results?: Array<{
|
||||||
|
success: boolean;
|
||||||
|
featureId?: string;
|
||||||
|
importedAt: string;
|
||||||
|
warnings?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
wasOverwritten?: boolean;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/features/import', { projectPath, data, ...options }),
|
||||||
|
checkConflicts: (
|
||||||
|
projectPath: string,
|
||||||
|
data: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
conflicts?: Array<{
|
||||||
|
featureId: string;
|
||||||
|
title?: string;
|
||||||
|
existingTitle?: string;
|
||||||
|
hasConflict: boolean;
|
||||||
|
}>;
|
||||||
|
totalFeatures?: number;
|
||||||
|
conflictCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/features/check-conflicts', { projectPath, data }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
|
|||||||
@@ -71,3 +71,58 @@ export interface Feature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified';
|
export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export format for a feature, used when exporting features to share or backup
|
||||||
|
*/
|
||||||
|
export interface FeatureExport {
|
||||||
|
/** Export format version for compatibility checking */
|
||||||
|
version: string;
|
||||||
|
/** The feature data being exported */
|
||||||
|
feature: Feature;
|
||||||
|
/** ISO date string when the export was created */
|
||||||
|
exportedAt: string;
|
||||||
|
/** Optional identifier of who/what performed the export */
|
||||||
|
exportedBy?: string;
|
||||||
|
/** Additional metadata about the export context */
|
||||||
|
metadata?: {
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
branch?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for importing a feature
|
||||||
|
*/
|
||||||
|
export interface FeatureImport {
|
||||||
|
/** The feature data to import (can be raw Feature or wrapped FeatureExport) */
|
||||||
|
data: Feature | FeatureExport;
|
||||||
|
/** Whether to overwrite an existing feature with the same ID */
|
||||||
|
overwrite?: boolean;
|
||||||
|
/** Whether to preserve the original branchName or ignore it */
|
||||||
|
preserveBranchInfo?: boolean;
|
||||||
|
/** Optional new ID to assign (if not provided, uses the feature's existing ID) */
|
||||||
|
newId?: string;
|
||||||
|
/** Optional new category to assign */
|
||||||
|
targetCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a feature import operation
|
||||||
|
*/
|
||||||
|
export interface FeatureImportResult {
|
||||||
|
/** Whether the import was successful */
|
||||||
|
success: boolean;
|
||||||
|
/** The ID of the imported feature */
|
||||||
|
featureId?: string;
|
||||||
|
/** ISO date string when the import was completed */
|
||||||
|
importedAt: string;
|
||||||
|
/** Non-fatal warnings encountered during import */
|
||||||
|
warnings?: string[];
|
||||||
|
/** Errors that caused import failure */
|
||||||
|
errors?: string[];
|
||||||
|
/** Whether an existing feature was overwritten */
|
||||||
|
wasOverwritten?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export type {
|
|||||||
FeatureTextFilePath,
|
FeatureTextFilePath,
|
||||||
FeatureStatus,
|
FeatureStatus,
|
||||||
DescriptionHistoryEntry,
|
DescriptionHistoryEntry,
|
||||||
|
FeatureExport,
|
||||||
|
FeatureImport,
|
||||||
|
FeatureImportResult,
|
||||||
} from './feature.js';
|
} from './feature.js';
|
||||||
|
|
||||||
// Session types
|
// Session types
|
||||||
|
|||||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -51,7 +51,8 @@
|
|||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"morgan": "1.10.1",
|
"morgan": "1.10.1",
|
||||||
"node-pty": "1.1.0-beta41",
|
"node-pty": "1.1.0-beta41",
|
||||||
"ws": "8.18.3"
|
"ws": "8.18.3",
|
||||||
|
"yaml": "2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie": "0.6.0",
|
"@types/cookie": "0.6.0",
|
||||||
@@ -81,6 +82,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/server/node_modules/yaml": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/ui": {
|
"apps/ui": {
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
@@ -6218,7 +6231,6 @@
|
|||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -6228,7 +6240,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -8439,7 +8451,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
@@ -11333,6 +11344,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11354,6 +11366,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11375,6 +11388,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11396,6 +11410,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11417,6 +11432,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11438,6 +11454,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11459,6 +11476,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11480,6 +11498,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11501,6 +11520,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11522,6 +11542,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11543,6 +11564,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user