Files
nrwl--nx/graph/migrate/src/lib/components/migration-card.tsx
T
2026-07-13 12:38:36 +08:00

484 lines
16 KiB
TypeScript

// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
// nx-ignore-next-line
import { FileChange } from 'nx/src/devkit-exports';
import {
ArrowPathIcon,
CheckCircleIcon,
ClockIcon,
CodeBracketIcon,
ExclamationCircleIcon,
ListBulletIcon,
PlayIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import { Pill } from '@nx/graph-internal-ui-project-details';
import {
useState,
forwardRef,
useImperativeHandle,
useEffect,
type ReactNode,
} from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { Interpreter } from 'xstate';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from '../state/automatic/types';
import { useSelector } from '@xstate/react';
import {
currentMigrationHasChanges,
getMigrationType,
isMigrationRunning,
} from '../state/automatic/selectors';
import {
isHybridShape,
isPromptOnlyShape,
type MigrationsJsonMetadata,
} from '../migration-shape';
export interface MigrationCardHandle {
expand: () => void;
collapse: () => void;
toggle: () => void;
}
function convertUrlsToLinks(text: string): ReactNode[] {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
const urls = text.match(urlRegex) || [];
const result: ReactNode[] = [];
for (let i = 0; i < parts.length; i++) {
if (urls[i - 1]) {
result.push(
<a
key={i}
rel="noopener noreferrer"
href={urls[i - 1]}
target="_blank"
className="text-blue-500 hover:underline"
>
{urls[i - 1]}
</a>
);
} else if (parts[i]) {
result.push(parts[i]);
}
}
return result;
}
export const MigrationCard = forwardRef<
MigrationCardHandle,
{
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
migration: MigrationDetailsWithId;
nxConsoleMetadata: MigrationsJsonMetadata;
isSelected?: boolean;
onSelect?: (isSelected: boolean) => void;
onRunMigration?: () => void;
onFileClick: (file: Omit<FileChange, 'content'>) => void;
onViewImplementation: () => void;
onViewDocumentation: () => void;
onViewPrompt: () => void;
isExpanded?: boolean;
}
>(function MigrationCard(
{
actor,
migration,
nxConsoleMetadata,
isSelected,
onSelect,
onRunMigration,
onFileClick,
onViewImplementation,
onViewDocumentation,
onViewPrompt,
isExpanded: isExpandedProp,
},
ref
) {
const [isExpanded, setIsExpanded] = useState(isExpandedProp ?? false);
useImperativeHandle(
ref,
() => ({
expand: () => setIsExpanded(true),
collapse: () => setIsExpanded(false),
toggle: () => setIsExpanded((prev) => !prev),
}),
[]
);
useEffect(() => {
if (isExpandedProp !== undefined) {
setIsExpanded(isExpandedProp);
}
}, [isExpandedProp]);
const migrationResult = nxConsoleMetadata.completedMigrations?.[migration.id];
const filesChanged =
migrationResult?.type === 'successful' ? migrationResult.changedFiles : [];
const nextSteps =
migrationResult?.type === 'successful' ? migrationResult.nextSteps : [];
const isPromptOnly = isPromptOnlyShape(migration);
const isHybrid = isHybridShape(migration);
const isPromptBearing = isPromptOnly || isHybrid;
const isSuccessful = migrationResult?.type === 'successful';
const acknowledgedPrompt =
isSuccessful && !!migrationResult.acknowledgedPrompt;
// For terminal non-success states the Failed/Skipped/Stopped pill in the
// card top-right already conveys the outcome — stay out of the way.
const showPromptStatusRow =
isPromptBearing && (!migrationResult || isSuccessful);
// The prompt-path reminder lives in the next-steps section for every
// prompt-bearing phase where running the prompt is (or was) actionable:
// prompt-only from the start, hybrid only once the generator succeeded.
const showPromptNextStep =
showPromptStatusRow && (isPromptOnly || isSuccessful) && !!migration.prompt;
const isSucceeded = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'successful'
);
const isFailed = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'failed'
);
const isSkipped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'skipped'
);
const isStopped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'stopped'
);
const hasChanges = useSelector(actor, (state) =>
currentMigrationHasChanges(state.context)
);
const isRunning = useSelector(actor, (state) =>
isMigrationRunning(state.context, migration.id)
);
const renderSelectBox = onSelect && isSelected !== undefined;
const isNxMigration =
migration.package.startsWith('@nx') || migration.package.startsWith('nx');
return (
<div
key={migration.id}
className={`gap-2 rounded-md p-2 transition-colors`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{renderSelectBox && (
<div className="h-4 w-4">
<input
checked={isSelected}
onChange={(e) => onSelect((e.target as any).checked)}
id={migration.id}
name={migration.id}
value={migration.id}
type="checkbox"
className={`h-4 w-4 ${
isSucceeded
? 'accent-green-600 dark:accent-green-500'
: isFailed
? 'accent-red-600 dark:accent-red-500'
: 'accent-blue-500 dark:accent-sky-500'
}`}
/>
</div>
)}
<div className={`flex flex-col gap-1`}>
<div
className={`flex items-center gap-2 ${
isNxMigration ? 'cursor-pointer gap-1 hover:underline' : ''
}`}
onClick={() => {
if (isNxMigration) {
onViewDocumentation();
}
}}
>
{/* <div>{migration.name}</div>
{isNxMigration && (
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
)} */}
</div>
<span className="mb-2 text-sm">{migration.description}</span>
<div className="flex items-center gap-2">
{migration.package && (
<Pill
text={`${migration.package}: ${migration.version}`}
color={'grey'}
/>
)}
{isPromptBearing && <AIBadge />}
</div>
{showPromptStatusRow && (
<PromptStatusRow
isHybrid={isHybrid}
isSuccessful={isSuccessful}
acknowledgedPrompt={acknowledgedPrompt}
/>
)}
</div>
</div>
<div className="flex items-center gap-2">
{' '}
{isSucceeded && !hasChanges && (
<Pill text="No changes made" color="green" />
)}
{isSucceeded && hasChanges && (
<div>
<div
className="cursor-pointer"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<Pill
key="changes"
text={`${filesChanged.length} changes`}
color="green"
/>
</div>
</div>
)}
{isFailed && (
<div>
<Pill text="Failed" color="red" />
</div>
)}
{isSkipped && (
<div>
<Pill text="Skipped" color="grey" />
</div>
)}
{isStopped && (
<div>
<Pill text="Stopped" color="yellow" />
</div>
)}
{onRunMigration && !isStopped && !isPromptOnly && (
<span
className={`rounded-md p-1 text-sm ring-1 transition-colors ring-inset ${
isSucceeded
? 'bg-green-50 text-green-700 ring-green-200 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-500 dark:ring-green-900/30 dark:hover:bg-green-900/30'
: isFailed
? 'bg-red-50 text-red-700 ring-red-200 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:ring-red-900/30 dark:hover:bg-red-900/30'
: 'bg-inherit text-slate-600 ring-slate-400/40 hover:bg-slate-200 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-700/60'
}`}
>
{isRunning ? (
<ArrowPathIcon
className="h-6 w-6 animate-spin cursor-not-allowed text-blue-500"
aria-label="Migration in progress"
/>
) : !isSucceeded && !isFailed && !isStopped ? (
<PlayIcon
onClick={onRunMigration}
className="h-6 w-6 !cursor-pointer"
aria-label="Run migration"
/>
) : (
<ArrowPathIcon
onClick={onRunMigration}
className="h-6 w-6 !cursor-pointer"
aria-label="Rerun migration"
/>
)}
</span>
)}
</div>
</div>
{(isSucceeded && nextSteps?.length) || showPromptNextStep ? (
<div className="pt-2">
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60" />
<span className="pb-2 text-sm font-bold">
More Information & Next Steps
</span>
<ul className="list-inside list-disc pl-2">
{nextSteps.map((step, idx) => (
<li key={idx} className="text-sm">
{convertUrlsToLinks(step)}
</li>
))}
{showPromptNextStep && (
<li className="text-sm">
Run the AI prompt at{' '}
<code
className="cursor-pointer text-sky-500 underline-offset-2 hover:underline dark:text-sky-300"
onClick={() => onViewPrompt()}
>
{migration.prompt}
</code>{' '}
to complete this migration.
</li>
)}
</ul>
<p></p>
</div>
) : null}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => onViewImplementation()}
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
<CodeBracketIcon className="h-4 w-4" />
View Source
</button>
{isFailed && (
<button
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<ExclamationCircleIcon className="h-4 w-4" />
{isExpanded ? 'Hide Errors' : 'View Errors'}
</button>
)}
{isSucceeded && hasChanges && (
<button
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<ListBulletIcon className="h-4 w-4" />
{isExpanded ? 'Hide Changes' : 'View Changes'}
</button>
)}
</div>
<AnimatePresence>
{isFailed && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex overflow-hidden pt-2"
>
<pre>{(migrationResult as any)?.error}</pre>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isStopped && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex overflow-hidden pt-2"
>
<pre>{(migrationResult as any)?.error}</pre>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isSucceeded && hasChanges && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60"></div>
<span className="pb-2 text-sm font-bold">File Changes</span>
<ul className="flex flex-col gap-2">
{filesChanged.map((file) => {
return (
<li
className="cursor-pointer text-sm hover:underline"
key={`${migration.id}-${file.path}`}
onClick={() => {
onFileClick(file);
}}
>
<code>{file.path}</code>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
function AIBadge() {
return (
<span
className="inline-flex items-center gap-1 rounded bg-sky-300/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-sky-300 uppercase ring-1 ring-sky-300/40 ring-inset"
aria-label="AI-assisted migration"
>
<SparklesIcon className="h-3 w-3" />
AI
</span>
);
}
function PromptStatusRow({
isHybrid,
isSuccessful,
acknowledgedPrompt,
}: {
isHybrid: boolean;
isSuccessful: boolean;
acknowledgedPrompt: boolean;
}) {
// Prompt-only is "completed" the moment the metadata records success (the
// short-circuit does that); hybrid needs the separate acknowledgment.
const isCompleted = isSuccessful && (!isHybrid || acknowledgedPrompt);
const showGeneratorComplete = isHybrid && isSuccessful && !isCompleted;
return (
<div className="mt-3 flex items-center gap-3 text-xs">
{isCompleted ? (
<span className="inline-flex items-center gap-1 text-green-500">
<CheckCircleIcon className="h-3.5 w-3.5" />
Completed
</span>
) : (
<>
{showGeneratorComplete && (
<>
<span className="inline-flex items-center gap-1 text-green-500">
<CheckCircleIcon className="h-3.5 w-3.5" />
Generator complete
</span>
<span className="text-slate-400/60">·</span>
</>
)}
<span className="inline-flex items-center gap-1 text-sky-300">
<ClockIcon className="h-3.5 w-3.5" />
AI prompt pending
</span>
</>
)}
</div>
);
}