nx/graph/migrate/src/lib/components/migration-card.tsx
Jack Hsu dcef5c7cf2
feat(graph): show next steps for successful migrations (#30934)
This PR updates Migration UI to display "next steps" when they are
provided by a migration. This works by writing `nextSteps` into the Nx
Console meta in `migrations.json`.

If the `nextSteps` is missing or it's empty, then nothing will be shown.

<img width="1555" alt="Screenshot 2025-04-29 at 5 16 49 PM"
src="https://github.com/user-attachments/assets/88491632-9b33-421a-887a-b6fbb5676098"
/>

See: https://www.loom.com/share/c0a4a7dce9df46b5b023fce5e0a3bd2f
2025-04-30 08:16:30 -04:00

327 lines
11 KiB
TypeScript

/* eslint-disable @nx/enforce-module-boundaries */
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import { FileChange } from 'nx/src/devkit-exports';
import type { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
/* eslint-enable @nx/enforce-module-boundaries */
import {
ArrowPathIcon,
CodeBracketIcon,
ExclamationCircleIcon,
ListBulletIcon,
PlayIcon,
} 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';
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}
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,
{
migration: MigrationDetailsWithId;
nxConsoleMetadata: MigrationsJsonMetadata;
isSelected?: boolean;
onSelect?: (isSelected: boolean) => void;
onRunMigration?: () => void;
onFileClick: (file: Omit<FileChange, 'content'>) => void;
onViewImplementation: () => void;
onViewDocumentation: () => void;
forceIsRunning?: boolean;
isExpanded?: boolean;
}
>(function MigrationCard(
{
migration,
nxConsoleMetadata,
isSelected,
onSelect,
onRunMigration,
onFileClick,
onViewImplementation,
onViewDocumentation,
forceIsRunning,
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 succeeded = migrationResult?.type === 'successful';
const failed = migrationResult?.type === 'failed';
const skipped = migrationResult?.type === 'skipped';
const inProgress = nxConsoleMetadata.runningMigrations?.includes(
migration.id
);
const madeChanges = succeeded && !!migrationResult?.changedFiles.length;
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 ${
succeeded
? 'accent-green-600 dark:accent-green-500'
: failed
? '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 gap-2">
{migration.package && (
<Pill
text={`${migration.package}: ${migration.version}`}
color={'grey'}
/>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{' '}
{succeeded && !madeChanges && (
<Pill text="No changes made" color="green" />
)}
{succeeded && madeChanges && (
<div>
<div
className="cursor-pointer"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<Pill
key="changes"
text={`${migrationResult?.changedFiles.length} changes`}
color="green"
/>
</div>
</div>
)}
{failed && (
<div>
<Pill text="Failed" color="red" />
</div>
)}
{skipped && (
<div>
<Pill text="Skipped" color="grey" />
</div>
)}
{(onRunMigration || forceIsRunning) && (
<span
className={`rounded-md p-1 text-sm ring-1 ring-inset transition-colors ${
succeeded
? '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'
: failed
? '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'
}`}
>
{inProgress || forceIsRunning ? (
<ArrowPathIcon
className="h-6 w-6 animate-spin cursor-not-allowed text-blue-500"
aria-label="Migration in progress"
/>
) : !succeeded && !failed ? (
<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>
{succeeded && migrationResult?.nextSteps?.length ? (
<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">
{migrationResult?.nextSteps.map((step, idx) => (
<li key={idx} className="text-sm">
{convertUrlsToLinks(step)}
</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-sm 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>
{failed && (
<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-sm 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>
)}
{succeeded && madeChanges && (
<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-sm 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>
{failed && 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?.error}</pre>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{succeeded && madeChanges && 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">
{migrationResult?.changedFiles.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>
);
});