nx/graph/migrate/src/lib/components/migration-timeline.tsx
Nicholas Cunningham a911318017
feat(graph): Create Migrate UI (#30734)
This PR introduces a new UI in Nx Console designed to assist users with
managing migrations more effectively.

Each migration is now presented with its status and actions, allowing
users to interact directly.
If any issues arise, users can address them in isolation without
disrupting the overall flow. The migrate ui provides a clear overview of
the migration state, helping users track progress and understand what
actions are required at each step.
2025-04-16 12:40:37 -04:00

565 lines
22 KiB
TypeScript

/* eslint-disable @nx/enforce-module-boundaries */
import { FileChange } from '@nx/devkit';
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import type { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
/* eslint-enable @nx/enforce-module-boundaries */
import {
ChevronUpIcon,
ChevronDownIcon,
ExclamationCircleIcon,
CheckCircleIcon,
ClockIcon,
MinusIcon,
} from '@heroicons/react/24/outline';
import { useEffect, useState, useRef } from 'react';
import { MigrationCard, MigrationCardHandle } from './migration-card';
import { Collapsible } from '@nx/graph/ui-common';
import { twMerge } from 'tailwind-merge';
export interface MigrationTimelineProps {
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
currentMigrationIndex: number;
currentMigrationRunning?: boolean;
currentMigrationFailed?: boolean;
currentMigrationSuccess?: boolean;
currentMigrationHasChanges?: boolean;
isDone?: boolean;
isInit: boolean;
onRunMigration: (migration: MigrationDetailsWithId) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
) => void;
onViewImplementation: (migration: MigrationDetailsWithId) => void;
onViewDocumentation: (migration: MigrationDetailsWithId) => void;
onCancel?: () => void;
onReviewMigration: (migrationId: string) => void;
}
export function MigrationTimeline({
migrations,
nxConsoleMetadata,
currentMigrationIndex,
currentMigrationRunning,
currentMigrationFailed,
currentMigrationSuccess,
currentMigrationHasChanges,
onRunMigration,
onSkipMigration,
onFileClick,
onViewImplementation,
onViewDocumentation,
onCancel,
onReviewMigration,
}: MigrationTimelineProps) {
const [showAllPastMigrations, setShowAllPastMigrations] = useState(false);
const [showAllFutureMigrations, setShowAllFutureMigrations] = useState(false);
const [expandedMigrations, setExpandedMigrations] = useState<{
[key: string]: boolean;
}>({});
const currentMigration = migrations[currentMigrationIndex];
const pastMigrations = migrations.slice(0, currentMigrationIndex);
const futureMigrations = migrations.slice(currentMigrationIndex + 1);
// Number of visible migrations when collapsed
const visiblePastCount = 0;
const visibleFutureCount = 2;
const visiblePastMigrations = showAllPastMigrations
? pastMigrations
: pastMigrations.slice(
Math.max(0, pastMigrations.length - visiblePastCount)
);
const visibleFutureMigrations = showAllFutureMigrations
? futureMigrations
: futureMigrations.slice(0, visibleFutureCount);
const hasPastMigrationsHidden =
pastMigrations.length > visiblePastCount && !showAllPastMigrations;
const hasFutureMigrationsHidden =
futureMigrations.length > visibleFutureCount && !showAllFutureMigrations;
const currentMigrationRef = useRef<MigrationCardHandle>(null);
// Auto-expand when entering a failed migration or requires review
useEffect(() => {
if (currentMigrationFailed || currentMigrationHasChanges) {
toggleMigrationExpanded(currentMigration.id, true);
}
}, [currentMigrationHasChanges, currentMigrationFailed, currentMigration]);
const toggleMigrationExpanded = (migrationId: string, state?: boolean) => {
setExpandedMigrations((prev) => ({
...prev,
[migrationId]: state ?? !prev[migrationId],
}));
};
return (
<div className="flex flex-col">
<div className="mb-6 flex w-full justify-between">
{onCancel && (
<button
onClick={onCancel}
className="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
Cancel the migration
</button>
)}
</div>
<div className="relative w-full pl-10">
{/* Timeline lines */}
{/* Solid line for visible migrations */}
<div
className="absolute left-10 top-0 w-0.5 bg-slate-200"
style={{
height: hasFutureMigrationsHidden ? 'calc(100% - 15%)' : '100%',
}}
></div>
{/* Dashed line for the section after the last visible migration */}
{hasFutureMigrationsHidden && (
<div
className="absolute bottom-0 left-10 w-0.5 border-l-2 border-dashed border-slate-200"
style={{
height: '15%',
}}
></div>
)}
{/* Timeline container */}
<div className="flex flex-col">
{/* Past migrations section */}
{pastMigrations.length > 0 && (
<>
{showAllPastMigrations && (
<div
key="show-past-migrations"
className="relative mb-6 w-full"
>
<TimelineButton
icon={ChevronDownIcon}
onClick={() => setShowAllPastMigrations(false)}
/>
<div className="ml-6">
<div
className="flex cursor-pointer items-center"
onClick={() => setShowAllPastMigrations(false)}
>
<span className="text-sm font-medium text-slate-600">
Hide Past Migrations
</span>
</div>
</div>
</div>
)}
{visiblePastMigrations.map((migration) => (
<div key={migration.id} className="relative mb-6 w-full">
<MigrationStateCircle
migration={migration}
nxConsoleMetadata={nxConsoleMetadata}
onClick={() => toggleMigrationExpanded(migration.id)}
/>
<div
className={twMerge(
`ml-6 mt-1`,
expandedMigrations[currentMigration.id] ? '-mt-1' : ''
)}
>
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-4 font-medium">
<span
onClick={() => toggleMigrationExpanded(migration.id)}
className={`flex-shrink-0 cursor-pointer whitespace-nowrap text-base ${
nxConsoleMetadata.completedMigrations?.[
migration.id
]?.type === 'successful'
? 'text-green-600'
: 'text-slate-600'
}`}
>
{migration.name}
</span>
{!expandedMigrations[migration.id] && (
<span className="w-0 flex-1 truncate text-sm text-slate-600/50">
{' '}
{migration.description}{' '}
</span>
)}
</div>
{expandedMigrations[migration.id] && (
<div className="flex gap-2">
{nxConsoleMetadata.completedMigrations?.[migration.id]
?.type === 'failed' && (
<button
onClick={() => {
toggleMigrationExpanded(migration.id);
onRunMigration(migration);
}}
type="button"
className="rounded-md border border-red-500 bg-red-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-600 dark:border-red-700 dark:bg-red-600 dark:text-white hover:dark:bg-red-700"
>
Rerun
</button>
)}
</div>
)}
</div>
<Collapsible
isOpen={expandedMigrations[migration.id]}
className="mt-2 w-full rounded-md border border-slate-300 p-3"
>
<MigrationCard
migration={migration}
isExpanded={expandedMigrations[migration.id]}
nxConsoleMetadata={nxConsoleMetadata}
onFileClick={(file) => onFileClick(migration, file)}
onViewImplementation={() =>
onViewImplementation(migration)
}
onViewDocumentation={() =>
onViewDocumentation(migration)
}
/>
</Collapsible>
</div>
</div>
))}
{hasPastMigrationsHidden && (
<div
key="show-past-migrations"
className="relative mb-6 w-full"
>
<TimelineButton
icon={ChevronUpIcon}
onClick={() => setShowAllPastMigrations(true)}
/>
<div className="ml-6">
<div
className="flex cursor-pointer items-center"
onClick={() => setShowAllPastMigrations(true)}
>
<span className="text-sm font-medium text-slate-600">
Show Past Migrations
</span>
</div>
</div>
</div>
)}
</>
)}
{/* Current migration */}
<div className="relative">
{/* TODO: Change this to be a clickable element li, button etc... */}
<div>
<MigrationStateCircle
migration={migrations[currentMigrationIndex]}
nxConsoleMetadata={nxConsoleMetadata}
isRunning={currentMigrationRunning}
onClick={() => toggleMigrationExpanded(currentMigration.id)}
/>
<div
className={twMerge(
`ml-6 mt-1`,
expandedMigrations[currentMigration.id] ? '-mt-1' : ''
)}
>
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-4 font-medium">
<span
className="flex-shrink-0 cursor-pointer whitespace-nowrap"
onClick={() =>
toggleMigrationExpanded(currentMigration.id)
}
>
{currentMigration.name}
</span>
{!expandedMigrations[currentMigration.id] && (
<p className="w-0 flex-1 truncate text-sm">
{currentMigration.description}
</p>
)}
</div>
{expandedMigrations[currentMigration.id] && (
<div className="flex flex-shrink-0 gap-2">
{currentMigrationFailed && (
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onRunMigration(currentMigration);
}}
type="button"
className="rounded-md border border-red-500 bg-red-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-600 dark:border-red-700 dark:bg-red-600 dark:text-white hover:dark:bg-red-700"
>
Rerun
</button>
)}
{!currentMigrationSuccess && (
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onSkipMigration(currentMigration);
}}
type="button"
className="rounded-md border border-slate-500 bg-slate-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-slate-600 dark:border-slate-600 dark:bg-slate-600 dark:text-white hover:dark:bg-slate-700"
>
Skip
</button>
)}
{currentMigrationHasChanges && (
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onReviewMigration(currentMigration.id);
}}
type="button"
className="flex items-center rounded-md border border-green-500 bg-green-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-600 dark:border-green-700 dark:bg-green-600 dark:text-white hover:dark:bg-green-700"
>
Approve Changes
</button>
)}
</div>
)}
</div>
<Collapsible
className="mt-2 w-full rounded-md border border-slate-300/60"
isOpen={expandedMigrations[currentMigration.id]}
>
{/* Migration Card */}
<MigrationCard
ref={currentMigrationRef}
migration={currentMigration}
isExpanded={expandedMigrations[currentMigration.id]}
nxConsoleMetadata={nxConsoleMetadata}
onFileClick={(file) => onFileClick(currentMigration, file)}
forceIsRunning={currentMigrationRunning}
onViewImplementation={() =>
onViewImplementation(currentMigration)
}
onViewDocumentation={() =>
onViewDocumentation(currentMigration)
}
/>
</Collapsible>
</div>
</div>
</div>
{/* Future migrations */}
{futureMigrations.length > 0 && (
<>
{visibleFutureMigrations.map((migration) => (
<div key={migration.id} className="relative mt-6 w-full">
<MigrationStateCircle
migration={migration}
nxConsoleMetadata={nxConsoleMetadata}
onClick={() => toggleMigrationExpanded(migration.id)}
/>
<div
className={twMerge(
`ml-6 mt-1`,
expandedMigrations[migration.id] &&
!nxConsoleMetadata.completedMigrations?.[migration.id]
? '-mt-1'
: ''
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-center gap-4">
<span
className="flex-shrink-0 cursor-pointer whitespace-nowrap"
onClick={() => toggleMigrationExpanded(migration.id)}
>
{migration.name}
</span>
{!expandedMigrations[migration.id] && (
<span className="w-0 flex-1 truncate text-sm text-slate-600/50">
{migration.description}{' '}
</span>
)}
</div>
{/* ONLY SHOW BUTTONS FOR PENDING MIGRATIONS */}
{expandedMigrations[migration.id] &&
!nxConsoleMetadata.completedMigrations?.[
migration.id
] && (
<div className="flex flex-shrink-0 gap-2">
<button
onClick={() => {
toggleMigrationExpanded(migration.id);
onSkipMigration(migration);
}}
type="button"
className="rounded-md border border-slate-500 bg-slate-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-slate-600 dark:border-slate-600 dark:bg-slate-600 dark:text-white hover:dark:bg-slate-700"
>
Skip
</button>
</div>
)}
</div>
<Collapsible
isOpen={expandedMigrations[migration.id]}
className="mt-2 w-full rounded-md border border-slate-300/50 p-3"
>
<MigrationCard
migration={migration}
nxConsoleMetadata={nxConsoleMetadata}
isExpanded={expandedMigrations[migration.id]}
onFileClick={(file) => onFileClick(migration, file)}
onViewImplementation={() =>
onViewImplementation(migration)
}
onViewDocumentation={() =>
onViewDocumentation(migration)
}
/>
</Collapsible>
</div>
</div>
))}
{hasFutureMigrationsHidden && (
<div
key="show-future-migrations"
className="relative mb-1 mt-9 w-full"
>
<TimelineButton
icon={ChevronDownIcon}
onClick={() => setShowAllFutureMigrations(true)}
/>
<div className="ml-6">
<div
className="flex cursor-pointer items-center"
onClick={() => setShowAllFutureMigrations(true)}
>
<span className="text-sm font-medium text-slate-600">
Show more
</span>
</div>
</div>
</div>
)}
{showAllFutureMigrations && (
<div
key="show-future-migrations"
className="relative mb-1 mt-6 w-full"
>
<TimelineButton
icon={ChevronUpIcon}
onClick={() => setShowAllFutureMigrations(false)}
/>
<div className="ml-6">
<div
className="flex cursor-pointer items-center"
onClick={() => setShowAllFutureMigrations(false)}
>
<span className="text-sm font-medium text-slate-600">
Show fewer
</span>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
interface TimelineButtonProps {
icon: React.ElementType;
onClick: () => void;
}
function TimelineButton({ icon: Icon, onClick }: TimelineButtonProps) {
return (
<div
className="absolute left-0 top-0 flex h-6 w-6 -translate-x-1/2 cursor-pointer items-center justify-center rounded-full bg-slate-300 text-slate-700"
onClick={onClick}
>
<Icon className="h-4 w-4" />
</div>
);
}
interface MigrationStateCircleProps {
migration: MigrationDetailsWithId;
nxConsoleMetadata: MigrationsJsonMetadata;
isRunning?: boolean;
onClick: () => void;
}
function MigrationStateCircle({
migration,
nxConsoleMetadata,
isRunning,
onClick,
}: MigrationStateCircleProps) {
let bgColor = '';
let textColor = '';
let Icon = ClockIcon;
// Check if this migration is in the completed migrations
const completedMigration =
nxConsoleMetadata.completedMigrations?.[migration.id];
const isSkipped = completedMigration?.type === 'skipped';
const isError = completedMigration?.type === 'failed';
const isSuccess = completedMigration?.type === 'successful';
if (isSkipped) {
bgColor = 'bg-slate-300';
textColor = 'text-slate-700';
Icon = MinusIcon;
} else if (isError) {
bgColor = 'bg-red-500';
textColor = 'text-white';
Icon = ExclamationCircleIcon;
} else if (isRunning) {
bgColor = 'bg-blue-500';
textColor = 'text-white';
Icon = ClockIcon;
} else if (isSuccess) {
bgColor = 'bg-green-500';
textColor = 'text-white';
Icon = CheckCircleIcon;
} else {
// Future migration (none of the states above)
bgColor = 'bg-slate-300';
textColor = 'text-slate-700';
Icon = ClockIcon;
}
return (
<div
className={`absolute left-0 top-0 flex h-8 w-8 -translate-x-1/2 cursor-pointer items-center justify-center rounded-full ${bgColor} ${textColor}`}
onClick={onClick}
>
{isRunning ? (
<span className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Icon className="h-6 w-6" />
)}
</div>
);
}