mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 15:22:55 +01:00
Finished inital project migration workflow
This commit is contained in:
@@ -23,6 +23,11 @@ export type DialogLayerOptions = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type ShowDialogOptions = DialogLayerOptions & {
|
||||
/** Called when the dialog is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerModelEvents> {
|
||||
public static instance = new DialogLayerModel();
|
||||
|
||||
@@ -84,4 +89,40 @@ export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerMo
|
||||
};
|
||||
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a custom dialog component.
|
||||
* Returns a close function that can be called to programmatically close the dialog.
|
||||
*
|
||||
* @param render - Function that receives a close callback and returns JSX
|
||||
* @param options - Dialog options including optional id
|
||||
* @returns A function to close the dialog
|
||||
*/
|
||||
public showDialog(
|
||||
render: (close: () => void) => JSX.Element,
|
||||
options: ShowDialogOptions = {}
|
||||
): () => void {
|
||||
const id = options.id ?? guid();
|
||||
const { onClose } = options;
|
||||
|
||||
const close = () => {
|
||||
this.closeById(id);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
// Remove existing dialog with same id if present
|
||||
if (this._dialogs[id]) {
|
||||
this._order = this._order.filter((x) => x !== id);
|
||||
delete this._dialogs[id];
|
||||
}
|
||||
|
||||
this._order.push(id);
|
||||
this._dialogs[id] = {
|
||||
id,
|
||||
slot: () => render(close)
|
||||
};
|
||||
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
|
||||
|
||||
return close;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import { filesystem } from '@noodl/platform';
|
||||
|
||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
|
||||
import {
|
||||
@@ -409,27 +411,72 @@ export class MigrationSessionManager extends EventDispatcher {
|
||||
}
|
||||
|
||||
private async executeCopyPhase(): Promise<void> {
|
||||
if (!this.session) return;
|
||||
|
||||
const sourcePath = this.session.source.path;
|
||||
const targetPath = this.session.target.path;
|
||||
|
||||
this.updateProgress({ phase: 'copying', current: 0 });
|
||||
this.addLogEntry({
|
||||
level: 'info',
|
||||
message: 'Creating project copy...'
|
||||
message: `Copying project from ${sourcePath} to ${targetPath}...`
|
||||
});
|
||||
|
||||
// TODO: Implement actual file copying using filesystem
|
||||
// For now, this is a placeholder
|
||||
try {
|
||||
// Check if target already exists
|
||||
const targetExists = await filesystem.exists(targetPath);
|
||||
if (targetExists) {
|
||||
throw new Error(`Target directory already exists: ${targetPath}`);
|
||||
}
|
||||
|
||||
await this.simulateDelay(500);
|
||||
// Create target directory
|
||||
await filesystem.makeDirectory(targetPath);
|
||||
|
||||
// Copy all files recursively
|
||||
await this.copyDirectoryRecursive(sourcePath, targetPath);
|
||||
|
||||
if (this.session) {
|
||||
this.session.target.copied = true;
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
message: 'Project copied successfully'
|
||||
});
|
||||
|
||||
this.updateProgress({ current: 1 });
|
||||
} catch (error) {
|
||||
this.addLogEntry({
|
||||
level: 'error',
|
||||
message: `Failed to copy project: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
message: 'Project copied successfully'
|
||||
});
|
||||
/**
|
||||
* Recursively copies a directory and its contents
|
||||
*/
|
||||
private async copyDirectoryRecursive(sourcePath: string, targetPath: string): Promise<void> {
|
||||
const entries = await filesystem.listDirectory(sourcePath);
|
||||
|
||||
this.updateProgress({ current: 1 });
|
||||
for (const entry of entries) {
|
||||
const sourceItemPath = entry.fullPath;
|
||||
const targetItemPath = `${targetPath}/${entry.name}`;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
// Skip node_modules and .git folders
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create directory and recurse
|
||||
await filesystem.makeDirectory(targetItemPath);
|
||||
await this.copyDirectoryRecursive(sourceItemPath, targetItemPath);
|
||||
} else {
|
||||
// Copy file
|
||||
const content = await filesystem.readFile(sourceItemPath);
|
||||
await filesystem.writeFile(targetItemPath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAutomaticPhase(): Promise<void> {
|
||||
@@ -493,14 +540,47 @@ export class MigrationSessionManager extends EventDispatcher {
|
||||
}
|
||||
|
||||
private async executeFinalizePhase(): Promise<void> {
|
||||
if (!this.session) return;
|
||||
|
||||
this.updateProgress({ phase: 'finalizing' });
|
||||
this.addLogEntry({
|
||||
level: 'info',
|
||||
message: 'Finalizing migration...'
|
||||
});
|
||||
|
||||
// TODO: Update project.json with migration metadata
|
||||
await this.simulateDelay(200);
|
||||
try {
|
||||
// Update project.json with migration metadata
|
||||
const targetProjectJsonPath = `${this.session.target.path}/project.json`;
|
||||
|
||||
// Read existing project.json
|
||||
const projectJson = await filesystem.readJson(targetProjectJsonPath) as Record<string, unknown>;
|
||||
|
||||
// Add React 19 markers
|
||||
projectJson.runtimeVersion = 'react19';
|
||||
projectJson.migratedFrom = {
|
||||
version: 'react17',
|
||||
date: new Date().toISOString(),
|
||||
originalPath: this.session.source.path,
|
||||
aiAssisted: this.session.ai?.enabled ?? false
|
||||
};
|
||||
|
||||
// Write updated project.json back
|
||||
await filesystem.writeFile(
|
||||
targetProjectJsonPath,
|
||||
JSON.stringify(projectJson, null, 2)
|
||||
);
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
message: 'Project marked as React 19'
|
||||
});
|
||||
} catch (error) {
|
||||
this.addLogEntry({
|
||||
level: 'warning',
|
||||
message: `Could not update project.json metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
// Don't throw - this is not a critical failure
|
||||
}
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
|
||||
@@ -315,12 +315,14 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Default: Unknown - could be either version
|
||||
// Default: Assume React 17 for older projects without explicit markers
|
||||
// Any project without runtimeVersion, migratedFrom, or a recent editorVersion
|
||||
// is most likely a legacy project from before OpenNoodl
|
||||
// ==========================================================================
|
||||
return {
|
||||
version: 'unknown',
|
||||
version: 'react17',
|
||||
confidence: 'low',
|
||||
indicators: ['No version indicators found - manual verification recommended']
|
||||
indicators: ['No React 19 markers found - assuming legacy React 17 project']
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -950,3 +950,128 @@
|
||||
overflow: hidden overlay;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Legacy Project Styles (React 17 Migration)
|
||||
------------------------------------------------------------------- */
|
||||
|
||||
/* Legacy project card modifier */
|
||||
.projects-item--legacy {
|
||||
border: 2px solid #d49517;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projects-item--legacy:hover {
|
||||
border-color: #fdb314;
|
||||
}
|
||||
|
||||
/* Legacy badge in top-right corner */
|
||||
.projects-item-legacy-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: rgba(212, 149, 23, 0.9);
|
||||
color: #000;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.projects-item-legacy-badge svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hidden class for conditional display */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Legacy project hover actions overlay */
|
||||
.projects-item-legacy-actions {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background: rgba(19, 19, 19, 0.95);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projects-item--legacy:hover .projects-item-legacy-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Migrate Project button */
|
||||
.projects-item-migrate-btn {
|
||||
background-color: #d49517;
|
||||
border: none;
|
||||
color: #000;
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.projects-item-migrate-btn:hover {
|
||||
background-color: #fdb314;
|
||||
}
|
||||
|
||||
/* Open Read-Only button */
|
||||
.projects-item-readonly-btn {
|
||||
background-color: transparent;
|
||||
border: 1px solid #666;
|
||||
color: #aaa;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.projects-item-readonly-btn:hover {
|
||||
background-color: #333;
|
||||
border-color: #888;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Runtime detection pending indicator */
|
||||
.projects-item-detecting {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.projects-item-detecting::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #666;
|
||||
border-top-color: #d49517;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="projects-main">
|
||||
<!-- project item template -->
|
||||
<div data-template="projects-item" class="projects-item projects-item-plate" data-click="onProjectItemClicked">
|
||||
<div data-template="projects-item" class="projects-item projects-item-plate" data-class="isLegacy:projects-item--legacy" data-click="onProjectItemClicked">
|
||||
|
||||
<div class="projects-item-thumb" style="position:absolute; left:0px; top:0px; width:100%; bottom:70px;">
|
||||
<div class="projects-item-cloud-download" style="width:100%; height:100%;">
|
||||
@@ -10,6 +10,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy project badge -->
|
||||
<div class="projects-item-legacy-badge" data-class="!isLegacy:hidden">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
|
||||
</svg>
|
||||
<span>Legacy</span>
|
||||
</div>
|
||||
|
||||
<div class="projects-item-label" style="position:absolute; bottom:36px; left:10px; right:10px;"
|
||||
data-click="onRenameProjectClicked">
|
||||
<span data-text="label" data-test="project-card-label"></span>
|
||||
@@ -28,6 +36,16 @@
|
||||
<img class="projects-remove-icon" src="../assets/images/sharp-clear-24px.svg">
|
||||
</div>
|
||||
|
||||
<!-- Legacy project hover actions -->
|
||||
<div class="projects-item-legacy-actions" data-class="!isLegacy:hidden">
|
||||
<button class="projects-item-migrate-btn" data-click="onMigrateProjectClicked">
|
||||
Migrate Project
|
||||
</button>
|
||||
<button class="projects-item-readonly-btn" data-click="onOpenReadOnlyClicked">
|
||||
Open Read-Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- tutorial item template, guides etc (not lessons) -->
|
||||
|
||||
@@ -13,6 +13,8 @@ import { templateRegistry } from '@noodl-utils/forge';
|
||||
|
||||
import Model from '../../../shared/model';
|
||||
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
||||
import { RuntimeVersionInfo } from '../models/migration/types';
|
||||
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
||||
import FileSystem from './filesystem';
|
||||
import { tracker } from './tracker';
|
||||
import { guid } from './utils';
|
||||
@@ -25,6 +27,14 @@ export interface ProjectItem {
|
||||
thumbURI: string;
|
||||
retainedProjectDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended project item with runtime version info (not persisted)
|
||||
*/
|
||||
export interface ProjectItemWithRuntime extends ProjectItem {
|
||||
runtimeInfo?: RuntimeVersionInfo;
|
||||
runtimeDetectionPending?: boolean;
|
||||
}
|
||||
export class LocalProjectsModel extends Model {
|
||||
public static instance = new LocalProjectsModel();
|
||||
|
||||
@@ -34,6 +44,17 @@ export class LocalProjectsModel extends Model {
|
||||
name: 'recently_opened_project'
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache for runtime version info - keyed by project directory path
|
||||
* Not persisted, re-detected on each app session
|
||||
*/
|
||||
private runtimeInfoCache: Map<string, RuntimeVersionInfo> = new Map();
|
||||
|
||||
/**
|
||||
* Set of project directories currently being detected
|
||||
*/
|
||||
private detectingProjects: Set<string> = new Set();
|
||||
|
||||
async fetch() {
|
||||
// Fetch projects from local storage and verify project folders
|
||||
const folders = (this.recentProjectsStore.get('recentProjects') || []) as ProjectItem[];
|
||||
@@ -299,4 +320,128 @@ export class LocalProjectsModel extends Model {
|
||||
|
||||
setRequestGitAccount(func);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Runtime Version Detection Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get cached runtime info for a project, or null if not yet detected
|
||||
* @param projectPath - The project directory path
|
||||
*/
|
||||
getRuntimeInfo(projectPath: string): RuntimeVersionInfo | null {
|
||||
return this.runtimeInfoCache.get(projectPath) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime detection is currently in progress for a project
|
||||
* @param projectPath - The project directory path
|
||||
*/
|
||||
isDetectingRuntime(projectPath: string): boolean {
|
||||
return this.detectingProjects.has(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get projects with their runtime info (extended interface)
|
||||
* Returns projects enriched with cached runtime detection status
|
||||
*/
|
||||
getProjectsWithRuntime(): ProjectItemWithRuntime[] {
|
||||
return this.projectEntries.map((project) => ({
|
||||
...project,
|
||||
runtimeInfo: this.getRuntimeInfo(project.retainedProjectDirectory),
|
||||
runtimeDetectionPending: this.isDetectingRuntime(project.retainedProjectDirectory)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect runtime version for a single project.
|
||||
* Results are cached and listeners are notified.
|
||||
* @param projectPath - Path to the project directory
|
||||
* @returns The detected runtime version info
|
||||
*/
|
||||
async detectProjectRuntime(projectPath: string): Promise<RuntimeVersionInfo> {
|
||||
// Return cached result if available
|
||||
const cached = this.runtimeInfoCache.get(projectPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Skip if already detecting
|
||||
if (this.detectingProjects.has(projectPath)) {
|
||||
// Wait for existing detection to complete by polling
|
||||
return new Promise((resolve) => {
|
||||
const checkCached = () => {
|
||||
const result = this.runtimeInfoCache.get(projectPath);
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else if (this.detectingProjects.has(projectPath)) {
|
||||
setTimeout(checkCached, 100);
|
||||
} else {
|
||||
// Detection finished but no result - return unknown
|
||||
resolve({ version: 'unknown', confidence: 'low', indicators: ['Detection failed'] });
|
||||
}
|
||||
};
|
||||
checkCached();
|
||||
});
|
||||
}
|
||||
|
||||
// Mark as detecting
|
||||
this.detectingProjects.add(projectPath);
|
||||
this.notifyListeners('runtimeDetectionStarted', projectPath);
|
||||
|
||||
try {
|
||||
const runtimeInfo = await detectRuntimeVersion(projectPath);
|
||||
this.runtimeInfoCache.set(projectPath, runtimeInfo);
|
||||
this.notifyListeners('runtimeDetectionComplete', projectPath, runtimeInfo);
|
||||
return runtimeInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to detect runtime for ${projectPath}:`, error);
|
||||
const fallback: RuntimeVersionInfo = {
|
||||
version: 'unknown',
|
||||
confidence: 'low',
|
||||
indicators: ['Detection error: ' + (error instanceof Error ? error.message : 'Unknown error')]
|
||||
};
|
||||
this.runtimeInfoCache.set(projectPath, fallback);
|
||||
this.notifyListeners('runtimeDetectionComplete', projectPath, fallback);
|
||||
return fallback;
|
||||
} finally {
|
||||
this.detectingProjects.delete(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect runtime version for all projects in the list (background)
|
||||
* Useful for pre-populating the cache when the projects view loads
|
||||
*/
|
||||
async detectAllProjectRuntimes(): Promise<void> {
|
||||
const projects = this.getProjects();
|
||||
|
||||
// Detect in parallel but don't wait for all to complete
|
||||
// Instead, trigger detection and let events update the UI
|
||||
for (const project of projects) {
|
||||
// Don't await - let them run in background
|
||||
this.detectProjectRuntime(project.retainedProjectDirectory).catch(() => {
|
||||
// Errors are handled in detectProjectRuntime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project is a legacy project (React 17)
|
||||
* @param projectPath - Path to the project directory
|
||||
* @returns True if project is detected as React 17
|
||||
*/
|
||||
isLegacyProject(projectPath: string): boolean {
|
||||
const info = this.getRuntimeInfo(projectPath);
|
||||
return info?.version === 'react17';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear runtime cache for a specific project (e.g., after migration)
|
||||
* @param projectPath - Path to the project directory
|
||||
*/
|
||||
clearRuntimeCache(projectPath: string): void {
|
||||
this.runtimeInfoCache.delete(projectPath);
|
||||
this.notifyListeners('runtimeCacheCleared', projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* MigrationWizard Styles
|
||||
*
|
||||
* Main container for the migration wizard using CoreBaseDialog.
|
||||
*/
|
||||
|
||||
.WizardContainer {
|
||||
position: relative;
|
||||
width: 700px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.WizardHeader {
|
||||
padding: 24px 24px 16px;
|
||||
padding-right: 48px; // Space for close button
|
||||
}
|
||||
|
||||
.WizardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 24px 24px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.StepContainer {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* MigrationWizard
|
||||
*
|
||||
* Main container component for the React 19 migration wizard.
|
||||
* Manages step navigation and integrates with MigrationSessionManager.
|
||||
*
|
||||
* @module noodl-editor/views/migration
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
|
||||
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { MigrationSession, MigrationScan, MigrationResult } from '../../models/migration/types';
|
||||
import { migrationSessionManager, getStepLabel, getStepNumber, getTotalSteps } from '../../models/migration/MigrationSession';
|
||||
|
||||
import { WizardProgress } from './components/WizardProgress';
|
||||
import { ConfirmStep } from './steps/ConfirmStep';
|
||||
import { ScanningStep } from './steps/ScanningStep';
|
||||
import { ReportStep } from './steps/ReportStep';
|
||||
import { CompleteStep } from './steps/CompleteStep';
|
||||
import { FailedStep } from './steps/FailedStep';
|
||||
|
||||
import css from './MigrationWizard.module.scss';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface MigrationWizardProps {
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Name of the project */
|
||||
projectName: string;
|
||||
/** Called when migration completes successfully */
|
||||
onComplete: (targetPath: string) => void;
|
||||
/** Called when wizard is cancelled */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type WizardAction =
|
||||
| { type: 'SET_SESSION'; session: MigrationSession }
|
||||
| { type: 'SET_TARGET_PATH'; path: string }
|
||||
| { type: 'START_SCAN' }
|
||||
| { type: 'SCAN_COMPLETE'; scan: MigrationScan }
|
||||
| { type: 'ERROR'; error: Error }
|
||||
| { type: 'START_MIGRATE'; useAi: boolean }
|
||||
| { type: 'MIGRATION_PROGRESS'; progress: number; currentComponent?: string }
|
||||
| { type: 'COMPLETE'; result: MigrationResult }
|
||||
| { type: 'RETRY' };
|
||||
|
||||
interface WizardState {
|
||||
session: MigrationSession | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reducer
|
||||
// =============================================================================
|
||||
|
||||
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
||||
switch (action.type) {
|
||||
case 'SET_SESSION':
|
||||
return {
|
||||
...state,
|
||||
session: action.session
|
||||
};
|
||||
|
||||
case 'SET_TARGET_PATH':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
target: { ...state.session.target, path: action.path }
|
||||
}
|
||||
};
|
||||
|
||||
case 'START_SCAN':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: { ...state.session, step: 'scanning' },
|
||||
loading: true
|
||||
};
|
||||
|
||||
case 'SCAN_COMPLETE':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'report',
|
||||
scan: action.scan
|
||||
},
|
||||
loading: false
|
||||
};
|
||||
|
||||
case 'ERROR':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: { ...state.session, step: 'failed' },
|
||||
loading: false,
|
||||
error: action.error
|
||||
};
|
||||
|
||||
case 'START_MIGRATE':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'migrating',
|
||||
ai: action.useAi ? state.session.ai : undefined
|
||||
},
|
||||
loading: true
|
||||
};
|
||||
|
||||
case 'MIGRATION_PROGRESS':
|
||||
if (!state.session?.progress) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
progress: {
|
||||
...state.session.progress,
|
||||
current: action.progress,
|
||||
currentComponent: action.currentComponent
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'COMPLETE':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'complete',
|
||||
result: action.result
|
||||
},
|
||||
loading: false
|
||||
};
|
||||
|
||||
case 'RETRY':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'confirm',
|
||||
scan: undefined,
|
||||
progress: undefined,
|
||||
result: undefined
|
||||
},
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function MigrationWizard({
|
||||
sourcePath,
|
||||
projectName,
|
||||
onComplete,
|
||||
onCancel
|
||||
}: MigrationWizardProps) {
|
||||
// Initialize session on mount
|
||||
const [state, dispatch] = useReducer(wizardReducer, {
|
||||
session: null,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Create session on mount
|
||||
useEffect(() => {
|
||||
async function initSession() {
|
||||
try {
|
||||
// Create session in manager (stores it internally)
|
||||
await migrationSessionManager.createSession(sourcePath, projectName);
|
||||
// Set default target path
|
||||
const defaultTargetPath = `${sourcePath}-react19`;
|
||||
migrationSessionManager.setTargetPath(defaultTargetPath);
|
||||
|
||||
// Update session with new target path
|
||||
const updatedSession = migrationSessionManager.getSession();
|
||||
if (updatedSession) {
|
||||
// Initialize reducer state with the session
|
||||
dispatch({ type: 'SET_SESSION', session: updatedSession });
|
||||
}
|
||||
setIsInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to create migration session:', error);
|
||||
dispatch({ type: 'ERROR', error: error as Error });
|
||||
}
|
||||
}
|
||||
initSession();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
migrationSessionManager.cancelSession();
|
||||
};
|
||||
}, [sourcePath, projectName]);
|
||||
|
||||
// Sync local state with session manager
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const currentSession = migrationSessionManager.getSession();
|
||||
if (currentSession) {
|
||||
// Initialize local state from session manager
|
||||
dispatch({ type: 'SET_TARGET_PATH', path: currentSession.target.path });
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
// ==========================================================================
|
||||
// Handlers
|
||||
// ==========================================================================
|
||||
|
||||
const handleUpdateTargetPath = useCallback((path: string) => {
|
||||
migrationSessionManager.setTargetPath(path);
|
||||
dispatch({ type: 'SET_TARGET_PATH', path });
|
||||
}, []);
|
||||
|
||||
const handleStartScan = useCallback(async () => {
|
||||
dispatch({ type: 'START_SCAN' });
|
||||
try {
|
||||
const scan = await migrationSessionManager.startScanning();
|
||||
dispatch({ type: 'SCAN_COMPLETE', scan });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'ERROR', error: error as Error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStartMigration = useCallback(async (useAi: boolean) => {
|
||||
dispatch({ type: 'START_MIGRATE', useAi });
|
||||
try {
|
||||
const result = await migrationSessionManager.startMigration();
|
||||
dispatch({ type: 'COMPLETE', result });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'ERROR', error: error as Error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
try {
|
||||
await migrationSessionManager.resetForRetry();
|
||||
dispatch({ type: 'RETRY' });
|
||||
} catch (error) {
|
||||
console.error('Failed to reset session:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenProject = useCallback(() => {
|
||||
const currentSession = migrationSessionManager.getSession();
|
||||
if (currentSession?.target.path) {
|
||||
onComplete(currentSession.target.path);
|
||||
}
|
||||
}, [onComplete]);
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
|
||||
// Get current session from manager (source of truth)
|
||||
const session = migrationSessionManager.getSession();
|
||||
|
||||
if (!session) {
|
||||
return null; // Session not initialized yet
|
||||
}
|
||||
|
||||
const currentStep = session.step;
|
||||
const stepIndex = getStepNumber(currentStep);
|
||||
const totalSteps = getTotalSteps(false); // No AI for now
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 'confirm':
|
||||
return (
|
||||
<ConfirmStep
|
||||
sourcePath={sourcePath}
|
||||
projectName={projectName}
|
||||
targetPath={session.target.path}
|
||||
onUpdateTargetPath={handleUpdateTargetPath}
|
||||
onNext={handleStartScan}
|
||||
onCancel={onCancel}
|
||||
loading={state.loading}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'scanning':
|
||||
return (
|
||||
<ScanningStep
|
||||
sourcePath={sourcePath}
|
||||
targetPath={session.target.path}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'report':
|
||||
return (
|
||||
<ReportStep
|
||||
scan={session.scan!}
|
||||
onMigrateWithoutAi={() => handleStartMigration(false)}
|
||||
onMigrateWithAi={() => handleStartMigration(true)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'migrating':
|
||||
return (
|
||||
<ScanningStep
|
||||
sourcePath={sourcePath}
|
||||
targetPath={session.target.path}
|
||||
isMigrating
|
||||
progress={session.progress}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<CompleteStep
|
||||
result={session.result!}
|
||||
sourcePath={sourcePath}
|
||||
targetPath={session.target.path}
|
||||
onOpenProject={handleOpenProject}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'failed':
|
||||
return (
|
||||
<FailedStep
|
||||
error={state.error}
|
||||
onRetry={handleRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
|
||||
<div className={css['WizardContainer']}>
|
||||
{/* Close Button */}
|
||||
<div className={css['CloseButton']}>
|
||||
<IconButton
|
||||
icon={IconName.Close}
|
||||
onClick={onCancel}
|
||||
variant={IconButtonVariant.Transparent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className={css['WizardHeader']}>
|
||||
<Title size={TitleSize.Large} variant={TitleVariant.Highlighted}>
|
||||
Migrate Project to React 19
|
||||
</Title>
|
||||
<Text textType={TextType.Secondary}>{getStepLabel(currentStep)}</Text>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={css['WizardContent']}>
|
||||
<WizardProgress
|
||||
currentStep={stepIndex}
|
||||
totalSteps={totalSteps}
|
||||
stepLabels={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
|
||||
/>
|
||||
<div className={css['StepContainer']}>
|
||||
{renderStep()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CoreBaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default MigrationWizard;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* WizardProgress Styles
|
||||
*
|
||||
* Step progress indicator for migration wizard.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.Step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.StepCircle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.Step.is-completed .StepCircle {
|
||||
background-color: var(--theme-color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.Step.is-active .StepCircle {
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.StepLabel {
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
text-align: center;
|
||||
max-width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Step.is-completed .StepLabel,
|
||||
.Step.is-active .StepLabel {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.Connector {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
margin-bottom: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.Connector.is-completed {
|
||||
background-color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.CheckIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* WizardProgress
|
||||
*
|
||||
* A visual progress indicator showing the current step in the migration wizard.
|
||||
*
|
||||
* @module noodl-editor/views/migration/components
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import css from './WizardProgress.module.scss';
|
||||
|
||||
export interface WizardProgressProps {
|
||||
/** Current step index (1-indexed) */
|
||||
currentStep: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Labels for each step */
|
||||
stepLabels: string[];
|
||||
}
|
||||
|
||||
export function WizardProgress({ currentStep, totalSteps, stepLabels }: WizardProgressProps) {
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<div className={css['Steps']}>
|
||||
{stepLabels.map((label, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isActive = stepNumber === currentStep;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={classNames(
|
||||
css['Step'],
|
||||
isActive && css['is-active'],
|
||||
isCompleted && css['is-completed']
|
||||
)}
|
||||
>
|
||||
<div className={css['StepIndicator']}>
|
||||
{isCompleted ? (
|
||||
<svg viewBox="0 0 16 16" className={css['CheckIcon']}>
|
||||
<path
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span>{stepNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={css['StepLabel']}>{label}</span>
|
||||
{index < stepLabels.length - 1 && (
|
||||
<div
|
||||
className={classNames(
|
||||
css['StepConnector'],
|
||||
isCompleted && css['is-completed']
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={css['ProgressBar']}>
|
||||
<div
|
||||
className={css['ProgressFill']}
|
||||
style={{ width: `${((currentStep - 1) / (totalSteps - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardProgress;
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* CompleteStep Styles
|
||||
*
|
||||
* Final step showing migration summary.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.Stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.StatCardIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.StatCard.is-success .StatCardIcon {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.StatCard.is-warning .StatCardIcon {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.StatCard.is-error .StatCardIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.StatCardValue {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.StatCardLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.MetaInfo {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.MetaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.Paths {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.PathItem {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
span:last-child {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.NextSteps {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.StepsList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* CompleteStep
|
||||
*
|
||||
* Step 5 of the migration wizard: Shows final summary.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { MigrationResult } from '../../../models/migration/types';
|
||||
|
||||
import css from './CompleteStep.module.scss';
|
||||
|
||||
export interface CompleteStepProps {
|
||||
/** Migration result */
|
||||
result: MigrationResult;
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Path to the migrated project */
|
||||
targetPath: string;
|
||||
/** Called when user wants to open the migrated project */
|
||||
onOpenProject: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function CompleteStep({
|
||||
result,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
onOpenProject
|
||||
}: CompleteStepProps) {
|
||||
const hasIssues = result.needsReview > 0;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
{hasIssues ? <CheckWarningIcon /> : <CheckCircleIcon />}
|
||||
<Title size={TitleSize.Medium}>
|
||||
{hasIssues ? 'Migration Complete (With Notes)' : 'Migration Complete!'}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Text textType={TextType.Secondary}>
|
||||
Your project has been migrated to React 19. The original project remains untouched.
|
||||
</Text>
|
||||
|
||||
{/* Stats */}
|
||||
<div className={css['Stats']}>
|
||||
<StatCard
|
||||
icon={<CheckIcon />}
|
||||
value={result.migrated}
|
||||
label="Migrated"
|
||||
variant="success"
|
||||
/>
|
||||
{result.needsReview > 0 && (
|
||||
<StatCard
|
||||
icon={<WarningIcon />}
|
||||
value={result.needsReview}
|
||||
label="Needs Review"
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<StatCard
|
||||
icon={<ErrorIcon />}
|
||||
value={result.failed}
|
||||
label="Failed"
|
||||
variant="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration and Cost */}
|
||||
<div className={css['MetaInfo']}>
|
||||
<div className={css['MetaItem']}>
|
||||
<ClockIcon />
|
||||
<Text size={TextSize.Small}>Time: {formatDuration(result.duration)}</Text>
|
||||
</div>
|
||||
{result.totalCost > 0 && (
|
||||
<div className={css['MetaItem']}>
|
||||
<RobotIcon />
|
||||
<Text size={TextSize.Small}>AI cost: ${result.totalCost.toFixed(2)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Paths */}
|
||||
<div className={css['Paths']}>
|
||||
<Title size={TitleSize.Small}>Project Locations</Title>
|
||||
|
||||
<div className={css['PathItem']}>
|
||||
<LockIcon />
|
||||
<div className={css['PathContent']}>
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>Original (untouched)</Text>
|
||||
<Text size={TextSize.Small}>{sourcePath}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css['PathItem']}>
|
||||
<FolderIcon />
|
||||
<div className={css['PathContent']}>
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>Migrated copy</Text>
|
||||
<Text size={TextSize.Small}>{targetPath}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What's Next */}
|
||||
<div className={css['NextSteps']}>
|
||||
<Title size={TitleSize.Small}>What's Next?</Title>
|
||||
<ol className={css['StepsList']}>
|
||||
{result.needsReview > 0 && (
|
||||
<li>
|
||||
<WarningIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
Components marked with ⚠️ have notes in the component panel -
|
||||
click to see migration details
|
||||
</Text>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<CheckIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
Test your app thoroughly before deploying
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<TrashIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
Once confirmed working, you can archive or delete the original folder
|
||||
</Text>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Open Migrated Project"
|
||||
onClick={onOpenProject}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-Components
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
value: number;
|
||||
label: string;
|
||||
variant: 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
function StatCard({ icon, value, label, variant }: StatCardProps) {
|
||||
return (
|
||||
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
|
||||
<div className={css['StatCardIcon']}>{icon}</div>
|
||||
<div className={css['StatCardValue']}>{value}</div>
|
||||
<div className={css['StatCardLabel']}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
function CheckCircleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={32} height={32}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckWarningIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={32} height={32}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zM8 3.5a.75.75 0 01.75.75V8h2.5a.75.75 0 110 1.5H8a.75.75 0 01-.75-.75V4.25A.75.75 0 018 3.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompleteStep;
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* ConfirmStep Styles
|
||||
*
|
||||
* First step of migration wizard - confirm source and target paths.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.PathSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.PathHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.LockIcon,
|
||||
.FolderIcon {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.PathFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.PathField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.PathLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathDisplay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.PathText {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.PathValue {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.PathInput {
|
||||
input {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.Arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.InfoBox {
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.StepsList {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--theme-color-fg-default);
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.WarningBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.WarningContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.WarningTitle {
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* ConfirmStep
|
||||
*
|
||||
* Step 1 of the migration wizard: Confirm source and target paths.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, ChangeEvent } from 'react';
|
||||
|
||||
import { FeedbackType } from '@noodl-constants/FeedbackType';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { filesystem } from '@noodl/platform';
|
||||
|
||||
import css from './ConfirmStep.module.scss';
|
||||
|
||||
export interface ConfirmStepProps {
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Name of the project */
|
||||
projectName: string;
|
||||
/** Current target path */
|
||||
targetPath: string;
|
||||
/** Called when target path changes */
|
||||
onUpdateTargetPath: (path: string) => void;
|
||||
/** Called when user proceeds to next step */
|
||||
onNext: () => void;
|
||||
/** Called when user cancels the wizard */
|
||||
onCancel: () => void;
|
||||
/** Whether the wizard is loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmStep({
|
||||
sourcePath,
|
||||
projectName,
|
||||
targetPath,
|
||||
onUpdateTargetPath,
|
||||
onNext,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: ConfirmStepProps) {
|
||||
const [targetExists, setTargetExists] = useState(false);
|
||||
const [checkingPath, setCheckingPath] = useState(false);
|
||||
|
||||
// Check if target path exists
|
||||
const checkTargetPath = useCallback(async (path: string) => {
|
||||
if (!path) {
|
||||
setTargetExists(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingPath(true);
|
||||
try {
|
||||
const exists = await filesystem.exists(path);
|
||||
setTargetExists(exists);
|
||||
} catch {
|
||||
setTargetExists(false);
|
||||
} finally {
|
||||
setCheckingPath(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkTargetPath(targetPath);
|
||||
}, [targetPath, checkTargetPath]);
|
||||
|
||||
const handleTargetChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onUpdateTargetPath(e.target.value);
|
||||
},
|
||||
[onUpdateTargetPath]
|
||||
);
|
||||
|
||||
const handleUseUniqueName = useCallback(() => {
|
||||
const timestamp = Date.now();
|
||||
const uniquePath = `${sourcePath}-react19-${timestamp}`;
|
||||
onUpdateTargetPath(uniquePath);
|
||||
}, [sourcePath, onUpdateTargetPath]);
|
||||
|
||||
const canProceed = targetPath && !targetExists && !loading && !checkingPath;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
<Box hasBottomSpacing>
|
||||
<Text>
|
||||
We'll create a safe copy of your project before making any changes.
|
||||
Your original project will remain untouched.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Source Project (Read-only) */}
|
||||
<div className={css['PathSection']}>
|
||||
<div className={css['PathHeader']}>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={css['LockIcon']}
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
<path
|
||||
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Title size={TitleSize.Small}>Original Project (will not be modified)</Title>
|
||||
</div>
|
||||
<div className={css['PathDisplay']}>
|
||||
<Text className={css['PathText']}>{sourcePath}</Text>
|
||||
<Text className={css['ProjectName']}>{projectName}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className={css['Arrow']}>
|
||||
<svg viewBox="0 0 16 16" width={20} height={20}>
|
||||
<path
|
||||
d="M8 2a.75.75 0 01.75.75v8.69l2.22-2.22a.75.75 0 111.06 1.06l-3.5 3.5a.75.75 0 01-1.06 0l-3.5-3.5a.75.75 0 111.06-1.06l2.22 2.22V2.75A.75.75 0 018 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Text size={TextSize.Small}>Creates copy</Text>
|
||||
</div>
|
||||
|
||||
{/* Target Path (Editable) */}
|
||||
<div className={css['PathSection']}>
|
||||
<div className={css['PathHeader']}>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={css['FolderIcon']}
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
<path
|
||||
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Title size={TitleSize.Small}>Migrated Copy Location</Title>
|
||||
</div>
|
||||
<TextInput
|
||||
value={targetPath}
|
||||
onChange={handleTargetChange}
|
||||
UNSAFE_className={css['PathInput']}
|
||||
/>
|
||||
{targetExists && (
|
||||
<div className={css['PathError']}>
|
||||
<Text textType={FeedbackType.Danger}>
|
||||
A folder already exists at this location.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
label="Use Different Name"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={handleUseUniqueName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* What happens next */}
|
||||
<Box hasTopSpacing>
|
||||
<div className={css['InfoBox']}>
|
||||
<Title size={TitleSize.Small}>What happens next:</Title>
|
||||
<ol className={css['StepsList']}>
|
||||
<li>Your project will be copied to the new location</li>
|
||||
<li>We'll scan for compatibility issues</li>
|
||||
<li>You'll see a report of what needs to change</li>
|
||||
<li>Automatic fixes will be applied</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onCancel}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={loading ? 'Starting...' : 'Start Migration'}
|
||||
onClick={onNext}
|
||||
isDisabled={!canProceed}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmStep;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* FailedStep Styles
|
||||
*
|
||||
* Error state when migration fails.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ErrorCircleIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.DescriptionText {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.ErrorBox {
|
||||
margin-top: 16px;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ErrorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorText {
|
||||
color: var(--theme-color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ErrorMessage {
|
||||
padding: 12px 16px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.Suggestions {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.SuggestionList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Link {
|
||||
color: var(--theme-color-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.SafetyNotice {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.SafetyText {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* FailedStep
|
||||
*
|
||||
* Step shown when migration fails. Allows user to retry or cancel.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import css from './FailedStep.module.scss';
|
||||
|
||||
export interface FailedStepProps {
|
||||
/** The error that caused the failure */
|
||||
error: Error | null;
|
||||
/** Called when user wants to retry */
|
||||
onRetry: () => void;
|
||||
/** Called when user wants to cancel */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function FailedStep({
|
||||
error,
|
||||
onRetry,
|
||||
onCancel
|
||||
}: FailedStepProps) {
|
||||
const errorMessage = error?.message || 'An unknown error occurred during migration.';
|
||||
const isNetworkError = errorMessage.toLowerCase().includes('network') ||
|
||||
errorMessage.toLowerCase().includes('timeout');
|
||||
const isPermissionError = errorMessage.toLowerCase().includes('permission') ||
|
||||
errorMessage.toLowerCase().includes('access');
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
<ErrorCircleIcon />
|
||||
<Title size={TitleSize.Medium}>
|
||||
Migration Failed
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Text className={css['DescriptionText']}>
|
||||
Something went wrong during the migration process. Your original project is safe and unchanged.
|
||||
</Text>
|
||||
|
||||
{/* Error Details */}
|
||||
<div className={css['ErrorBox']}>
|
||||
<div className={css['ErrorHeader']}>
|
||||
<ErrorIcon />
|
||||
<Text className={css['ErrorText']}>Error Details</Text>
|
||||
</div>
|
||||
<div className={css['ErrorMessage']}>
|
||||
<Text size={TextSize.Small}>{errorMessage}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
<div className={css['Suggestions']}>
|
||||
<Title size={TitleSize.Small}>What you can try:</Title>
|
||||
<ul className={css['SuggestionList']}>
|
||||
{isNetworkError && (
|
||||
<li>
|
||||
<WifiIcon />
|
||||
<Text size={TextSize.Small}>Check your internet connection and try again</Text>
|
||||
</li>
|
||||
)}
|
||||
{isPermissionError && (
|
||||
<li>
|
||||
<LockIcon />
|
||||
<Text size={TextSize.Small}>Make sure you have write access to the target directory</Text>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<RefreshIcon />
|
||||
<Text size={TextSize.Small}>Click "Try Again" to restart the migration</Text>
|
||||
</li>
|
||||
<li>
|
||||
<FolderIcon />
|
||||
<Text size={TextSize.Small}>Try choosing a different target directory</Text>
|
||||
</li>
|
||||
<li>
|
||||
<HelpIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
If the problem persists, check the{' '}
|
||||
<a
|
||||
href="https://github.com/The-Low-Code-Foundation/OpenNoodl/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={css['Link']}
|
||||
>
|
||||
GitHub Issues
|
||||
</a>
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Safety Notice */}
|
||||
<div className={css['SafetyNotice']}>
|
||||
<ShieldIcon />
|
||||
<Text size={TextSize.Small} className={css['SafetyText']}>
|
||||
Your original project remains untouched. Any partial migration files have been cleaned up.
|
||||
</Text>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Try Again"
|
||||
onClick={onRetry}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
function ErrorCircleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={32} height={32} className={css['ErrorCircleIcon']}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm0-11.5a.75.75 0 01.75.75v3.5a.75.75 0 11-1.5 0v-3.5A.75.75 0 018 4.5zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WifiIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 12a1.5 1.5 0 100 3 1.5 1.5 0 000-3zM1.332 5.084a.75.75 0 10.97 1.142 9.5 9.5 0 0113.396 0 .75.75 0 00.97-1.142 11 11 0 00-15.336 0zm2.91 2.908a.75.75 0 10.97 1.142 5.5 5.5 0 017.576 0 .75.75 0 00.97-1.142 7 7 0 00-9.516 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 3a5 5 0 00-4.546 2.914.5.5 0 01-.908-.414A6 6 0 0113.944 5H12.5a.5.5 0 010-1h3a.5.5 0 01.5.5v3a.5.5 0 11-1 0V6.057A5.956 5.956 0 018 3zM0 8a.5.5 0 01.5-.5h1a.5.5 0 010 1H.5A.5.5 0 010 8zm1.5 2.5a.5.5 0 01.5.5v1.443A5.956 5.956 0 008 13a5 5 0 004.546-2.914.5.5 0 01.908.414A6 6 0 012.056 11H3.5a.5.5 0 010 1h-3a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zm0-10.25a2.25 2.25 0 00-2.25 2.25.75.75 0 001.5 0 .75.75 0 111.5 0c0 .52-.3.866-.658 1.075-.368.216-.842.425-.842 1.175a.75.75 0 001.5 0c0-.15.099-.282.282-.394.187-.114.486-.291.727-.524A2.25 2.25 0 008 4.25zM8 13a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M7.467.133a1.75 1.75 0 011.066 0l5.25 1.68A1.75 1.75 0 0115 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.7 1.7 0 01-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 011.217-1.667l5.25-1.68zm.61 1.429a.25.25 0 00-.153 0l-5.25 1.68a.25.25 0 00-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.2.2 0 00.154 0c2.245-.956 3.582-2.103 4.366-3.298C13.225 9.666 13.5 8.358 13.5 7V3.48a.25.25 0 00-.174-.238l-5.25-1.68zM11.28 6.28a.75.75 0 00-1.06-1.06L7.25 8.19 5.78 6.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l3.5-3.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FailedStep;
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* ReportStep Styles
|
||||
*
|
||||
* Scan results report with categories and AI options.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.StatsRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.StatCardIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.StatCard.is-automatic .StatCardIcon {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.StatCard.is-simpleFixes .StatCardIcon {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.StatCard.is-needsReview .StatCardIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.StatCardValue {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.StatCardLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.Categories {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.CategorySection {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.CategoryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.CategoryIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.CategorySection.is-automatic .CategoryIcon {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.CategorySection.is-simpleFixes .CategoryIcon {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.CategorySection.is-needsReview .CategoryIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.CategoryTitle {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.CategoryCount {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.ExpandIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.CategorySection.is-expanded .ExpandIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ComponentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ComponentItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentName {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ComponentIssueCount {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.AiPromptSection {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.AiPromptHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #8b5cf6; // AI purple
|
||||
}
|
||||
}
|
||||
|
||||
.AiPromptTitle {
|
||||
font-weight: 500;
|
||||
color: #8b5cf6; // AI purple
|
||||
}
|
||||
|
||||
.AiPromptSection.is-disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* ReportStep
|
||||
*
|
||||
* Step 3 of the migration wizard: Shows scan results by category.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { MigrationScan, ComponentMigrationInfo } from '../../../models/migration/types';
|
||||
|
||||
import css from './ReportStep.module.scss';
|
||||
|
||||
export interface ReportStepProps {
|
||||
/** Scan results */
|
||||
scan: MigrationScan;
|
||||
/** Called when user chooses to migrate without AI */
|
||||
onMigrateWithoutAi: () => void;
|
||||
/** Called when user chooses to migrate with AI */
|
||||
onMigrateWithAi: () => void;
|
||||
/** Called when user cancels */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ReportStep({
|
||||
scan,
|
||||
onMigrateWithoutAi,
|
||||
onMigrateWithAi,
|
||||
onCancel
|
||||
}: ReportStepProps) {
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
|
||||
const { automatic, simpleFixes, needsReview } = scan.categories;
|
||||
|
||||
const totalIssues = simpleFixes.length + needsReview.length;
|
||||
const allAutomatic = totalIssues === 0;
|
||||
|
||||
// Calculate estimated cost (placeholder - AI not yet implemented)
|
||||
const estimatedCost = needsReview.length * 0.05 + simpleFixes.length * 0.02;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
<Text>
|
||||
Analyzed {scan.totalComponents} components and {scan.totalNodes} nodes.
|
||||
{scan.customJsFiles > 0 && ` Found ${scan.customJsFiles} custom JavaScript files.`}
|
||||
</Text>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className={css['SummaryStats']}>
|
||||
<StatCard
|
||||
icon={<CheckCircleIcon />}
|
||||
value={automatic.length}
|
||||
label="Automatic"
|
||||
variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ZapIcon />}
|
||||
value={simpleFixes.length}
|
||||
label="Simple Fixes"
|
||||
variant="info"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ToolIcon />}
|
||||
value={needsReview.length}
|
||||
label="Needs Review"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Sections */}
|
||||
<div className={css['Categories']}>
|
||||
{/* Automatic */}
|
||||
<CategorySection
|
||||
title="Automatic"
|
||||
description="These components will migrate without any changes"
|
||||
icon={<CheckCircleIcon />}
|
||||
items={automatic}
|
||||
variant="success"
|
||||
expanded={expandedCategory === 'automatic'}
|
||||
onToggle={() =>
|
||||
setExpandedCategory(expandedCategory === 'automatic' ? null : 'automatic')
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Simple Fixes */}
|
||||
{simpleFixes.length > 0 && (
|
||||
<CategorySection
|
||||
title="Simple Fixes"
|
||||
description="Minor syntax updates needed"
|
||||
icon={<ZapIcon />}
|
||||
items={simpleFixes}
|
||||
variant="info"
|
||||
expanded={expandedCategory === 'simpleFixes'}
|
||||
onToggle={() =>
|
||||
setExpandedCategory(expandedCategory === 'simpleFixes' ? null : 'simpleFixes')
|
||||
}
|
||||
showIssueDetails
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Needs Review */}
|
||||
{needsReview.length > 0 && (
|
||||
<CategorySection
|
||||
title="Needs Review"
|
||||
description="May require manual adjustment after migration"
|
||||
icon={<ToolIcon />}
|
||||
items={needsReview}
|
||||
variant="warning"
|
||||
expanded={expandedCategory === 'needsReview'}
|
||||
onToggle={() =>
|
||||
setExpandedCategory(expandedCategory === 'needsReview' ? null : 'needsReview')
|
||||
}
|
||||
showIssueDetails
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Prompt (if there are issues) */}
|
||||
{!allAutomatic && (
|
||||
<div className={css['AiPrompt']}>
|
||||
<div className={css['AiPromptIcon']}>
|
||||
<RobotIcon />
|
||||
</div>
|
||||
<div className={css['AiPromptContent']}>
|
||||
<Title size={TitleSize.Small}>AI-Assisted Migration (Coming Soon)</Title>
|
||||
<Text textType={TextType.Secondary} size={TextSize.Small}>
|
||||
Claude can help automatically fix the {totalIssues} components that need
|
||||
code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={allAutomatic ? 'Migrate Project' : 'Migrate (Auto Only)'}
|
||||
onClick={onMigrateWithoutAi}
|
||||
/>
|
||||
{!allAutomatic && (
|
||||
<PrimaryButton
|
||||
label="Migrate with AI"
|
||||
onClick={onMigrateWithAi}
|
||||
isDisabled // AI not yet implemented
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-Components
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
value: number;
|
||||
label: string;
|
||||
variant: 'success' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
function StatCard({ icon, value, label, variant }: StatCardProps) {
|
||||
return (
|
||||
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
|
||||
<div className={css['StatCardIcon']}>{icon}</div>
|
||||
<div className={css['StatCardValue']}>{value}</div>
|
||||
<div className={css['StatCardLabel']}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
items: ComponentMigrationInfo[];
|
||||
variant: 'success' | 'info' | 'warning';
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
showIssueDetails?: boolean;
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
items,
|
||||
variant,
|
||||
expanded,
|
||||
onToggle,
|
||||
showIssueDetails = false
|
||||
}: CategorySectionProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`${css['CategorySection']} ${css[`is-${variant}`]}`}>
|
||||
<button className={css['CategoryHeader']} onClick={onToggle}>
|
||||
<div className={css['CategoryHeaderLeft']}>
|
||||
{icon}
|
||||
<div>
|
||||
<Text textType={TextType.Proud}>
|
||||
{title} ({items.length})
|
||||
</Text>
|
||||
<Text textType={TextType.Secondary} size={TextSize.Small}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronIcon direction={expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
|
||||
<Collapsible isCollapsed={!expanded}>
|
||||
<div className={css['CategoryItems']}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={css['CategoryItem']}>
|
||||
<ComponentIcon />
|
||||
<div className={css['CategoryItemInfo']}>
|
||||
<Text size={TextSize.Small}>{item.name}</Text>
|
||||
{showIssueDetails && item.issues.length > 0 && (
|
||||
<ul className={css['IssuesList']}>
|
||||
{item.issues.map((issue) => (
|
||||
<li key={issue.id}>
|
||||
<code>{issue.type}</code>
|
||||
<Text size={TextSize.Small} isSpan textType={TextType.Secondary}>
|
||||
{' '}{issue.description}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{item.estimatedCost !== undefined && (
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>
|
||||
~${item.estimatedCost.toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
function CheckCircleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={16} height={16}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZapIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={16} height={16}>
|
||||
<path
|
||||
d="M9.504.43a.75.75 0 01.397.696L9.223 5h4.027a.75.75 0 01.577 1.22l-5.25 6.25a.75.75 0 01-1.327-.55l.678-4.42H3.902a.75.75 0 01-.577-1.22l5.25-6.25a.75.75 0 01.93-.18z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={16} height={16}>
|
||||
<path
|
||||
d="M5.433 2.304A4.492 4.492 0 003.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64l-.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 003.5 6a5.99 5.99 0 012.433-4.864.75.75 0 011.134.64v3.046l.5.865.5-.865V1.776a.75.75 0 011.134-.64A5.99 5.99 0 0111.5 6a4.495 4.495 0 01-.922 3.802c-.518.328-.929.923-.902 1.64l.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 007.5 6c0-.54-.185-1.061-.433-1.548"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={24} height={24}>
|
||||
<path
|
||||
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8.186 1.113a.5.5 0 00-.372 0L1.814 3.5l-.372.149v8.702l.372.149 6 2.387a.5.5 0 00.372 0l6-2.387.372-.149V3.649l-.372-.149-6-2.387zM8 2.123l4.586 1.828L8 5.778 3.414 3.95 8 2.123zm-5.5 2.89l5 1.992v6.372l-5-1.992V5.013zm6.5 8.364V7.005l5-1.992v6.372l-5 1.992z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ direction }: { direction: 'up' | 'down' }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ transform: direction === 'up' ? 'rotate(180deg)' : undefined }}
|
||||
>
|
||||
<path
|
||||
d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportStep;
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* ScanningStep Styles
|
||||
*
|
||||
* Scanning/migrating progress display.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ProgressSection {
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ProgressHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ProgressBar {
|
||||
height: 8px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ProgressFill {
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.ActivityLog {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ActivityHeader {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ActivityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.ActivityItem {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
|
||||
&.is-info {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ActivityTime {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ActivityMessage {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.EmptyActivity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.InfoBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* ScanningStep
|
||||
*
|
||||
* Step 2 of the migration wizard: Shows progress while copying and scanning.
|
||||
* Also used during the migration phase (step 4).
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { MigrationProgress } from '../../../models/migration/types';
|
||||
|
||||
import css from './ScanningStep.module.scss';
|
||||
|
||||
export interface ScanningStepProps {
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Path to the target project */
|
||||
targetPath: string;
|
||||
/** Whether we're in migration phase (vs scanning phase) */
|
||||
isMigrating?: boolean;
|
||||
/** Progress information (for migration phase) */
|
||||
progress?: MigrationProgress;
|
||||
}
|
||||
|
||||
export function ScanningStep({
|
||||
sourcePath: _sourcePath,
|
||||
targetPath: _targetPath,
|
||||
isMigrating = false,
|
||||
progress
|
||||
}: ScanningStepProps) {
|
||||
// sourcePath and targetPath are available for future use (e.g., displaying paths)
|
||||
void _sourcePath;
|
||||
void _targetPath;
|
||||
const title = isMigrating ? 'Migrating Project...' : 'Analyzing Project...';
|
||||
const subtitle = isMigrating
|
||||
? `Phase: ${getPhaseLabel(progress?.phase)}`
|
||||
: 'Creating a safe copy before making any changes';
|
||||
|
||||
const progressPercent = progress
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
<div className={css['Header']}>
|
||||
<ActivityIndicator />
|
||||
<Title size={TitleSize.Medium}>{title}</Title>
|
||||
</div>
|
||||
|
||||
<Text textType={TextType.Secondary}>{subtitle}</Text>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className={css['ProgressSection']}>
|
||||
<div className={css['ProgressBar']}>
|
||||
<div
|
||||
className={css['ProgressFill']}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress && (
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>
|
||||
{progress.current} / {progress.total} components
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Item */}
|
||||
{progress?.currentComponent && (
|
||||
<div className={css['CurrentItem']}>
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Text size={TextSize.Small}>{progress.currentComponent}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Entries */}
|
||||
{progress?.log && progress.log.length > 0 && (
|
||||
<div className={css['LogSection']}>
|
||||
<Title size={TitleSize.Small}>Activity Log</Title>
|
||||
<div className={css['LogEntries']}>
|
||||
{progress.log.slice(-5).map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${css['LogEntry']} ${css[`is-${entry.level}`]}`}
|
||||
>
|
||||
<LogIcon level={entry.level} />
|
||||
<div className={css['LogContent']}>
|
||||
{entry.component && (
|
||||
<Text
|
||||
size={TextSize.Small}
|
||||
textType={TextType.Proud}
|
||||
isSpan
|
||||
>
|
||||
{entry.component}:{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={TextSize.Small} isSpan>
|
||||
{entry.message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Box hasTopSpacing>
|
||||
<div className={css['InfoBox']}>
|
||||
<Text textType={TextType.Shy} size={TextSize.Small}>
|
||||
{isMigrating
|
||||
? 'Please wait while we migrate your project. This may take a few minutes for larger projects.'
|
||||
: 'Scanning components for React 17 patterns that need updating...'}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper Components
|
||||
function LogIcon({ level }: { level: string }) {
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
info: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm.93-9.412l-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287h.001zm-.043-3.33a.86.86 0 110 1.72.86.86 0 010-1.72z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
success: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
return icons[level] || icons.info;
|
||||
}
|
||||
|
||||
function getPhaseLabel(phase?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
copying: 'Copying files',
|
||||
automatic: 'Applying automatic fixes',
|
||||
'ai-assisted': 'AI-assisted migration',
|
||||
finalizing: 'Finalizing'
|
||||
};
|
||||
return labels[phase || ''] || 'Starting';
|
||||
}
|
||||
|
||||
export default ScanningStep;
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { filesystem, platform } from '@noodl/platform';
|
||||
|
||||
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
|
||||
import { LessonsProjectsModel } from '@noodl-models/LessonsProjectModel';
|
||||
import { CloudServiceMetadata } from '@noodl-models/projectmodel';
|
||||
import { setCloudServices } from '@noodl-models/projectmodel.editor';
|
||||
import { LocalProjectsModel, ProjectItem } from '@noodl-utils/LocalProjectsModel';
|
||||
import { LocalProjectsModel, ProjectItem, ProjectItemWithRuntime } from '@noodl-utils/LocalProjectsModel';
|
||||
|
||||
import { MigrationWizard } from './migration/MigrationWizard';
|
||||
|
||||
import View from '../../../shared/view';
|
||||
import LessonTemplatesModel from '../models/lessontemplatesmodel';
|
||||
@@ -29,6 +33,10 @@ type ProjectItemScope = {
|
||||
project: ProjectItem;
|
||||
label: string;
|
||||
latestAccessedTimeAgo: string;
|
||||
/** Whether the project uses legacy React 17 runtime */
|
||||
isLegacy: boolean;
|
||||
/** Whether runtime detection is in progress */
|
||||
isDetecting: boolean;
|
||||
};
|
||||
|
||||
export class ProjectsView extends View {
|
||||
@@ -111,6 +119,9 @@ export class ProjectsView extends View {
|
||||
this.renderProjectItemsPane();
|
||||
|
||||
this.projectsModel.on('myProjectsChanged', () => this.renderProjectItemsPane(), this);
|
||||
|
||||
// Re-render when runtime detection completes to update legacy indicators
|
||||
this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);
|
||||
|
||||
this.switchPane('projects');
|
||||
|
||||
@@ -274,27 +285,34 @@ export class ProjectsView extends View {
|
||||
}) {
|
||||
options = options || {};
|
||||
|
||||
const items = options.items;
|
||||
const projectItems = options.items || [];
|
||||
const projectItemsSelector = options.appendProjectItemsTo || '.projects-items';
|
||||
const template = options.template || 'projects-item';
|
||||
this.$(projectItemsSelector).html('');
|
||||
|
||||
for (const i in items) {
|
||||
const label = items[i].name;
|
||||
for (const item of projectItems) {
|
||||
const label = item.name;
|
||||
if (options.filter && label.toLowerCase().indexOf(options.filter) === -1) continue;
|
||||
|
||||
const latestAccessed = items[i].latestAccessed || Date.now();
|
||||
const latestAccessed = item.latestAccessed || Date.now();
|
||||
|
||||
// Check if this is a legacy React 17 project
|
||||
const projectPath = item.retainedProjectDirectory;
|
||||
const isLegacy = projectPath ? this.projectsModel.isLegacyProject(projectPath) : false;
|
||||
const isDetecting = projectPath ? this.projectsModel.isDetectingRuntime(projectPath) : false;
|
||||
|
||||
const scope: ProjectItemScope = {
|
||||
project: items[i],
|
||||
project: item,
|
||||
label: label,
|
||||
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago'
|
||||
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago',
|
||||
isLegacy,
|
||||
isDetecting
|
||||
};
|
||||
|
||||
const el = this.bindView(this.cloneTemplate(template), scope);
|
||||
if (items[i].thumbURI) {
|
||||
if (item.thumbURI) {
|
||||
// Set the thumbnail image if there is one
|
||||
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + items[i].thumbURI + ')');
|
||||
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + item.thumbURI + ')');
|
||||
} else {
|
||||
// No thumbnail, show cloud download icon
|
||||
View.$(el, '.projects-item-cloud-download').show();
|
||||
@@ -302,6 +320,12 @@ export class ProjectsView extends View {
|
||||
|
||||
this.$(projectItemsSelector).append(el);
|
||||
}
|
||||
|
||||
// Trigger background runtime detection for all projects
|
||||
this.projectsModel.detectAllProjectRuntimes().then(() => {
|
||||
// Re-render after detection completes (if any legacy projects found)
|
||||
// The on('runtimeDetected') listener handles this
|
||||
});
|
||||
}
|
||||
|
||||
renderTutorialItems() {
|
||||
@@ -633,6 +657,107 @@ export class ProjectsView extends View {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user clicks "Migrate Project" on a legacy project card.
|
||||
* Opens the migration wizard dialog.
|
||||
*/
|
||||
onMigrateProjectClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
|
||||
evt.stopPropagation();
|
||||
|
||||
const projectPath = scope.project.retainedProjectDirectory;
|
||||
if (!projectPath) {
|
||||
ToastLayer.showError('Cannot migrate project: path not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the migration wizard as a dialog
|
||||
DialogLayerModel.instance.showDialog(
|
||||
(close) =>
|
||||
React.createElement(MigrationWizard, {
|
||||
sourcePath: projectPath,
|
||||
projectName: scope.project.name,
|
||||
onComplete: async (targetPath: string) => {
|
||||
close();
|
||||
// Clear runtime cache for the source project
|
||||
this.projectsModel.clearRuntimeCache(projectPath);
|
||||
|
||||
// Show activity indicator
|
||||
const activityId = 'opening-migrated-project';
|
||||
ToastLayer.showActivity('Opening migrated project', activityId);
|
||||
|
||||
try {
|
||||
// Open the migrated project from the target path
|
||||
const project = await this.projectsModel.openProjectFromFolder(targetPath);
|
||||
|
||||
if (!project.name) {
|
||||
project.name = scope.project.name + ' (React 19)';
|
||||
}
|
||||
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showSuccess('Project migrated successfully!');
|
||||
|
||||
// Open the migrated project
|
||||
this.notifyListeners('projectLoaded', project);
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Project migrated but could not open automatically. Check your projects list.');
|
||||
console.error('Failed to open migrated project:', error);
|
||||
// Refresh project list anyway
|
||||
this.projectsModel.fetch();
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
close();
|
||||
}
|
||||
}),
|
||||
{
|
||||
onClose: () => {
|
||||
// Refresh project list when dialog closes
|
||||
this.projectsModel.fetch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
tracker.track('Migration Wizard Opened', {
|
||||
projectName: scope.project.name
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user clicks "Open Read-Only" on a legacy project card.
|
||||
* Opens the project in read-only mode without migration.
|
||||
* Note: The project will open normally; legacy banner display
|
||||
* will be handled by the EditorBanner component based on runtime detection.
|
||||
*/
|
||||
async onOpenReadOnlyClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
|
||||
evt.stopPropagation();
|
||||
|
||||
const activityId = 'opening-project-readonly';
|
||||
ToastLayer.showActivity('Opening project in read-only mode', activityId);
|
||||
|
||||
try {
|
||||
const project = await this.projectsModel.loadProject(scope.project);
|
||||
ToastLayer.hideActivity(activityId);
|
||||
|
||||
if (!project) {
|
||||
ToastLayer.showError("Couldn't load project.");
|
||||
return;
|
||||
}
|
||||
|
||||
tracker.track('Legacy Project Opened Read-Only', {
|
||||
projectName: scope.project.name
|
||||
});
|
||||
|
||||
// Open the project - the EditorBanner will detect legacy runtime
|
||||
// and display a warning banner automatically
|
||||
this.notifyListeners('projectLoaded', project);
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Could not open project');
|
||||
console.error('Failed to open legacy project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Import a project from a URL
|
||||
importFromUrl(uri) {
|
||||
// Extract and remove query from url
|
||||
|
||||
@@ -6,6 +6,15 @@ const URL = require('url');
|
||||
|
||||
var port = process.env.NOODL_CLOUD_FUNCTIONS_PORT || 8577;
|
||||
|
||||
// Safe console.log wrapper to prevent EPIPE errors when stdout is broken
|
||||
function safeLog(...args) {
|
||||
try {
|
||||
console.log(...args);
|
||||
} catch (e) {
|
||||
// Ignore EPIPE errors - stdout pipe may be broken
|
||||
}
|
||||
}
|
||||
|
||||
function guid() {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
@@ -30,7 +39,7 @@ function openCloudRuntimeDevTools() {
|
||||
mode: 'detach'
|
||||
});
|
||||
} else {
|
||||
console.log('No cloud sandbox active');
|
||||
safeLog('No cloud sandbox active');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +71,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
show: false
|
||||
});
|
||||
|
||||
console.log('starting cloud runtime');
|
||||
safeLog('starting cloud runtime');
|
||||
|
||||
hasLoadedProject = false;
|
||||
|
||||
@@ -103,9 +112,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
headers: args.headers
|
||||
};
|
||||
|
||||
console.log('noodl-cf-fetch');
|
||||
console.log(_options);
|
||||
console.log(args.body);
|
||||
safeLog('noodl-cf-fetch');
|
||||
safeLog(_options);
|
||||
safeLog(args.body);
|
||||
|
||||
const httpx = url.protocol === 'https:' ? https : http;
|
||||
const req = httpx.request(_options, (res) => {
|
||||
@@ -120,13 +129,13 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
body: _data,
|
||||
status: res.statusCode
|
||||
};
|
||||
console.log('response', _response);
|
||||
safeLog('response', _response);
|
||||
sandbox.webContents.send('noodl-cf-fetch-response', _response);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log('error', error);
|
||||
safeLog('error', error);
|
||||
sandbox.webContents.send('noodl-cf-fetch-response', {
|
||||
token,
|
||||
error: error
|
||||
@@ -161,9 +170,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
if (path.startsWith('/functions/')) {
|
||||
const functionName = decodeURIComponent(path.split('/')[2]);
|
||||
|
||||
console.log('Calling cloud function: ' + functionName);
|
||||
safeLog('Calling cloud function: ' + functionName);
|
||||
if (!sandbox) {
|
||||
console.log('Error: No cloud runtime active...');
|
||||
safeLog('Error: No cloud runtime active...');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,7 +193,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('with body ', body);
|
||||
safeLog('with body ', body);
|
||||
|
||||
const token = guid();
|
||||
_responseHandlers[token] = (args) => {
|
||||
@@ -205,7 +214,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
safeLog(e);
|
||||
|
||||
response.writeHead(400, headers);
|
||||
response.end(JSON.stringify({ error: 'Failed to run function.' }));
|
||||
@@ -218,7 +227,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
|
||||
var server;
|
||||
if (process.env.ssl) {
|
||||
console.log('Using SSL');
|
||||
safeLog('Using SSL');
|
||||
|
||||
const options = {
|
||||
key: fs.readFileSync(process.env.sslKey),
|
||||
@@ -244,8 +253,8 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
});
|
||||
});
|
||||
|
||||
server.on('listening', (e) => {
|
||||
console.log('noodl cloud functions server running on port', port);
|
||||
server.on('listening', () => {
|
||||
safeLog('noodl cloud functions server running on port', port);
|
||||
process.env.NOODL_CLOUD_FUNCTIONS_PORT = port;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user