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.
This commit is contained in:
Nicholas Cunningham 2025-04-16 10:40:37 -06:00 committed by GitHub
parent 67732d6217
commit a911318017
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 5151 additions and 963 deletions

3
.gitignore vendored
View File

@ -66,3 +66,6 @@ target
/wasi-sdk* /wasi-sdk*
vite.config.*.timestamp* vite.config.*.timestamp*
storybook-static

View File

@ -1414,6 +1414,14 @@
"children": [], "children": [],
"disableCollapsible": false "disableCollapsible": false
}, },
{
"name": "Migrate UI",
"path": "/recipes/nx-console/console-migrate-ui",
"id": "console-migrate-ui",
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{ {
"name": "Run Command", "name": "Run Command",
"path": "/recipes/nx-console/console-run-command", "path": "/recipes/nx-console/console-run-command",
@ -2772,6 +2780,14 @@
"children": [], "children": [],
"disableCollapsible": false "disableCollapsible": false
}, },
{
"name": "Migrate UI",
"path": "/recipes/nx-console/console-migrate-ui",
"id": "console-migrate-ui",
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{ {
"name": "Run Command", "name": "Run Command",
"path": "/recipes/nx-console/console-run-command", "path": "/recipes/nx-console/console-run-command",
@ -2823,6 +2839,14 @@
"children": [], "children": [],
"disableCollapsible": false "disableCollapsible": false
}, },
{
"name": "Migrate UI",
"path": "/recipes/nx-console/console-migrate-ui",
"id": "console-migrate-ui",
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{ {
"name": "Run Command", "name": "Run Command",
"path": "/recipes/nx-console/console-run-command", "path": "/recipes/nx-console/console-run-command",

View File

@ -1937,6 +1937,17 @@
"path": "/recipes/nx-console/console-generate-command", "path": "/recipes/nx-console/console-generate-command",
"tags": ["editor-setup"] "tags": ["editor-setup"]
}, },
{
"id": "console-migrate-ui",
"name": "Migrate UI",
"description": "",
"mediaImage": "",
"file": "shared/recipes/console-migrate-ui",
"itemList": [],
"isExternal": false,
"path": "/recipes/nx-console/console-migrate-ui",
"tags": ["editor-setup"]
},
{ {
"id": "console-run-command", "id": "console-run-command",
"name": "Run Command", "name": "Run Command",
@ -3798,6 +3809,17 @@
"path": "/recipes/nx-console/console-generate-command", "path": "/recipes/nx-console/console-generate-command",
"tags": ["editor-setup"] "tags": ["editor-setup"]
}, },
{
"id": "console-migrate-ui",
"name": "Migrate UI",
"description": "",
"mediaImage": "",
"file": "shared/recipes/console-migrate-ui",
"itemList": [],
"isExternal": false,
"path": "/recipes/nx-console/console-migrate-ui",
"tags": ["editor-setup"]
},
{ {
"id": "console-run-command", "id": "console-run-command",
"name": "Run Command", "name": "Run Command",
@ -3869,6 +3891,17 @@
"path": "/recipes/nx-console/console-generate-command", "path": "/recipes/nx-console/console-generate-command",
"tags": ["editor-setup"] "tags": ["editor-setup"]
}, },
"/recipes/nx-console/console-migrate-ui": {
"id": "console-migrate-ui",
"name": "Migrate UI",
"description": "",
"mediaImage": "",
"file": "shared/recipes/console-migrate-ui",
"itemList": [],
"isExternal": false,
"path": "/recipes/nx-console/console-migrate-ui",
"tags": ["editor-setup"]
},
"/recipes/nx-console/console-run-command": { "/recipes/nx-console/console-run-command": {
"id": "console-run-command", "id": "console-run-command",
"name": "Run Command", "name": "Run Command",

View File

@ -28,6 +28,13 @@
"name": "Generate Command", "name": "Generate Command",
"path": "/recipes/nx-console/console-generate-command" "path": "/recipes/nx-console/console-generate-command"
}, },
{
"description": "",
"file": "shared/recipes/console-migrate-ui",
"id": "console-migrate-ui",
"name": "Migrate UI",
"path": "/recipes/nx-console/console-migrate-ui"
},
{ {
"description": "", "description": "",
"file": "shared/recipes/console-run-command", "file": "shared/recipes/console-run-command",

View File

@ -631,6 +631,12 @@
"tags": ["editor-setup"], "tags": ["editor-setup"],
"file": "shared/recipes/console-generate-command" "file": "shared/recipes/console-generate-command"
}, },
{
"name": "Migrate UI",
"id": "console-migrate-ui",
"tags": ["editor-setup"],
"file": "shared/recipes/console-migrate-ui"
},
{ {
"name": "Run Command", "name": "Run Command",
"id": "console-run-command", "id": "console-run-command",

View File

@ -0,0 +1,40 @@
---
title: 'Nx Console Migrate UI'
description: Overview over the Migrate UI feature for guided migrations in Nx Console
---
# Nx Console Migrate UI
The Nx Console Migrate UI provides a visual, guided way to execute migrations in your workspace while following Nx best practices. This tool simplifies the process of upgrading your Nx workspace by offering an easy-to-use interface that walks you through each step of the migration process.
## Accessing the Migrate UI
The Migrate UI is available on the latest version of Nx Console in VSCode or Cursor if you have enabled the `nxConsole.enableMigrateUi` setting. If enabled, a dedicated `Nx Migrate` view will appear in the sidebar. Unless you're on the latest version of Nx, you'll notice a button prompting you to start the migration.
{% callout type="note" %}
Ensure you have stashed or committed all your changes before initiating a migration.
{% /callout %}
## Starting a Migration
By default, clicking the migration button starts the migration process by upgrading to the recommended Nx version — the latest version of the next major release. This method ensures you upgrade one major version at a time in order to [avoid breakages](recipes/tips-n-tricks/advanced-update#one-major-version-at-a-time-small-steps)
{%callout type="note"%}
If you need more control, a smaller edit button is provided. You can use it to customize the migration process by specifying an exact version and passing custom CLI options like `--to` or `--from`.
{% /callout %}
Once you start the migration, Nx Console runs the `nx migrate` command to update your dependency versions and generate a `migrations.json` file. You'll be prompted to inspect the changes made to your `package.json` before installing them and proceedig.
## The Migration Process
After confirming the changes, the Migrate UI opens and guides you step-by-step through each migration action. Simply click on `Run Migrations` and each migration will be executed in order.
If a migration step encounters an error, the process pauses so you can inspect the error details.
You can click through to view the migration source code, giving you the opportunity to patch it for your specific use-case or make necessary adjustments to your repository before rerunning the migration.
Alternatively, you may choose to skip a problematic migration.
For successful migration steps that modify files, the UI pauses to let you review the changes. You must approve these changes before the migration continues.
## Finalizing the Migration
When all migration steps are complete or you don't want to run further migrations, you can finish the process by clicking the Finish button. By default, this will squash all commits created during the migration together, but you can opt into preserving them.

View File

@ -96,6 +96,7 @@
- [Telemetry](/recipes/nx-console/console-telemetry) - [Telemetry](/recipes/nx-console/console-telemetry)
- [Project Details View](/recipes/nx-console/console-project-details) - [Project Details View](/recipes/nx-console/console-project-details)
- [Generate Command](/recipes/nx-console/console-generate-command) - [Generate Command](/recipes/nx-console/console-generate-command)
- [Migrate UI](/recipes/nx-console/console-migrate-ui)
- [Run Command](/recipes/nx-console/console-run-command) - [Run Command](/recipes/nx-console/console-run-command)
- [Nx Cloud Integration](/recipes/nx-console/console-nx-cloud) - [Nx Cloud Integration](/recipes/nx-console/console-nx-cloud)
- [Troubleshooting](/recipes/nx-console/console-troubleshooting) - [Troubleshooting](/recipes/nx-console/console-troubleshooting)

View File

@ -0,0 +1,117 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
// nx-ignore-next-line
import { type FileChange } from 'nx/src/devkit-exports';
// nx-ignore-next-line
import { MigrateUI } from '@nx/graph-migrate';
/* eslint-enable @nx/enforce-module-boundaries */
import { getExternalApiService } from '@nx/graph/legacy/shared';
import { useSelector } from '@xstate/react';
import { Interpreter } from 'xstate';
import { MigrateEvents, MigrateState } from './migrate.machine';
export function MigrateApp({
service,
}: {
service: Interpreter<MigrateState, any, MigrateEvents>;
}) {
const externalApiService = getExternalApiService();
const onRunMigration = (
migration: MigrationDetailsWithId,
configuration: {
createCommits: boolean;
}
) => {
externalApiService.postEvent({
type: 'run-migration',
payload: {
migration,
configuration,
},
});
};
const onRunMany = (
migrations: MigrationDetailsWithId[],
configuration: {
createCommits: boolean;
}
) => {
externalApiService.postEvent({
type: 'run-many',
payload: {
migrations,
configuration,
},
});
};
const onCancel = () => {
externalApiService.postEvent({
type: 'cancel',
});
};
const onFinish = (squashCommits: boolean) => {
externalApiService.postEvent({
type: 'finish',
payload: {
squashCommits,
},
});
};
const migrations = useSelector(service, (state) => state.context.migrations);
const nxConsoleMetadata = useSelector(
service,
(state) => state.context.nxConsoleMetadata
);
const onFileClick = (migration: MigrationDetailsWithId, file: FileChange) => {
externalApiService.postEvent({
type: 'file-click',
payload: {
path: file.path,
migration: migration,
},
});
};
const onSkipMigration = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'skip-migration',
payload: { migration },
});
};
const onViewImplementation = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'view-implementation',
payload: { migration },
});
};
const onViewDocumentation = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'view-documentation',
payload: { migration },
});
};
return (
<MigrateUI
migrations={migrations}
nxConsoleMetadata={nxConsoleMetadata}
onRunMigration={onRunMigration}
onRunMany={onRunMany}
onCancel={onCancel}
onFinish={onFinish}
onFileClick={onFileClick}
onSkipMigration={onSkipMigration}
onViewImplementation={onViewImplementation}
onViewDocumentation={onViewDocumentation}
></MigrateUI>
);
}

View File

@ -0,0 +1,63 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import { assign } from '@xstate/immer';
import type {
GeneratedMigrationDetails,
MigrationDetailsWithId,
} from 'nx/src/config/misc-interfaces';
// nx-ignore-next-line
import { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
/* eslint-enable @nx/enforce-module-boundaries */
import { createMachine } from 'xstate';
export interface MigrateState {
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
}
export type MigrateEvents = {
type: 'loadData';
migrations: GeneratedMigrationDetails[];
'nx-console': MigrationsJsonMetadata;
};
const initialContext: MigrateState = {
migrations: [],
nxConsoleMetadata: {},
};
export const migrateMachine = createMachine<MigrateState, MigrateEvents>({
/** @xstate-layout N4IgpgJg5mDOIC5QFsCWUBOBDALmAxADYD2WEAIrlgNoAMAuoqAA7Gyo6rEB2TIAHogAsAJgA0IAJ6IAjAGYAnADoAHDJFCAbAFYVtGTNpHtAXzMTuxCHD5pMuMH1btOPPoIQBaTROlfNSkZBwSEA7OYgdth4SqgQhI5IIM4cXLxJHpoySiIqcira2gqitEJBPlKIakpyIiIKMrql6kIqKhFRDkokZJBObKluGbKaynU6oXKKIjKaU+KVCLMiSqG02uvF2jqaeiJmZkA */
predictableActionArguments: true,
preserveActionOrder: true,
id: 'migrate',
initial: 'idle',
context: initialContext,
states: {
idle: {},
loaded: {},
},
on: {
loadData: [
{
target: 'loaded',
actions: [
assign((ctx, event) => {
ctx.migrations = event.migrations.map((migration, index) => {
const duplicateCount = event.migrations
.slice(0, index)
.filter((m) => m.name === migration.name).length;
return {
...migration,
id:
duplicateCount === 0
? migration.name
: `${migration.name}-${duplicateCount}`,
};
});
ctx.nxConsoleMetadata = event['nx-console'];
}),
],
},
],
},
});

View File

@ -33,6 +33,9 @@ export declare global {
data: any data: any
) => Interpreter<ProjectDetailsState, any, ProjectDetailsEvents>; ) => Interpreter<ProjectDetailsState, any, ProjectDetailsEvents>;
renderError?: (data: any) => void; renderError?: (data: any) => void;
renderMigrate?: (
data: any
) => Interpreter<MigrateState, any, MigrateEvents>;
} }
} }
declare module 'cytoscape' { declare module 'cytoscape' {

View File

@ -10,6 +10,10 @@ import { projectDetailsMachine } from './app/console-project-details/project-det
import type { ProjectGraphProjectNode } from '@nx/devkit'; import type { ProjectGraphProjectNode } from '@nx/devkit';
// nx-ignore-next-line // nx-ignore-next-line
import type { GraphError } from 'nx/src/command-line/graph/graph'; import type { GraphError } from 'nx/src/command-line/graph/graph';
// nx-ignore-next-line
import { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
// nx-ignore-next-line
import { GeneratedMigrationDetails } from 'nx/src/config/misc-interfaces';
/* eslint-enable @nx/enforce-module-boundaries */ /* eslint-enable @nx/enforce-module-boundaries */
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { inspect } from '@xstate/inspect'; import { inspect } from '@xstate/inspect';
@ -19,6 +23,8 @@ import { render } from 'preact';
import { ErrorPage } from './app/ui-components/error-page'; import { ErrorPage } from './app/ui-components/error-page';
import { ProjectDetailsApp } from './app/console-project-details/project-details.app'; import { ProjectDetailsApp } from './app/console-project-details/project-details.app';
import { interpret } from 'xstate'; import { interpret } from 'xstate';
import { MigrateApp } from './app/console-migrate/migrate.app';
import { migrateMachine } from './app/console-migrate/migrate.machine';
if (window.__NX_RENDER_GRAPH__ === false) { if (window.__NX_RENDER_GRAPH__ === false) {
window.externalApi = new ExternalApiImpl(); window.externalApi = new ExternalApiImpl();
@ -58,6 +64,27 @@ if (window.__NX_RENDER_GRAPH__ === false) {
document.getElementById('app') document.getElementById('app')
); );
}; };
window.renderMigrate = (data: {
migrations: GeneratedMigrationDetails[];
'nx-console': MigrationsJsonMetadata;
}) => {
const service = interpret(migrateMachine).start();
service.send({
type: 'loadData',
...data,
});
render(
<StrictMode>
<MigrateApp service={service} />
</StrictMode>,
document.getElementById('app')
);
return service;
};
} else { } else {
if (window.useXstateInspect === true) { if (window.useXstateInspect === true) {
inspect({ inspect({

12
graph/migrate/.babelrc Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "storybook-static"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,29 @@
/* eslint-disable @nx/enforce-module-boundaries */
import type { StorybookConfig } from '@storybook/react-vite';
// nx-ignore-next-line
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { mergeConfig } from 'vite';
const config: StorybookConfig = {
stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: {
name: '@storybook/react-vite',
options: {},
},
viteFinal: async (config) =>
mergeConfig(config, {
plugins: [nxViteTsPaths()],
}),
docs: {},
};
export default config;
// To customize your Vite configuration you can use the viteFinal field.
// Check https://storybook.js.org/docs/react/builders/vite#configuration
// and https://nx.dev/recipes/storybook/custom-builder-configs

View File

@ -0,0 +1,2 @@
import './tailwind.css';
export const tags = ['autodocs'];

View File

@ -0,0 +1,33 @@
@tailwind components;
@tailwind base;
@tailwind utilities;
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out forwards;
}
.animate-fadeOut {
animation: fadeOut 0.2s ease-out forwards;
}

7
graph/migrate/README.md Normal file
View File

@ -0,0 +1,7 @@
# graph-migrate
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test graph-migrate` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -0,0 +1,19 @@
/* eslint-disable */
// nx-ignore-next-line
const nxPreset = require('@nx/jest/preset').default;
export default {
...nxPreset,
displayName: 'graph-migrate',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/graph/migrate',
// The mock for widnow.matchMedia has to be in a separete file and imported before the components to test
// for more info check : // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
modulePathIgnorePatterns: [
'/graph/client/src/app/machines/match-media-mock.spec.ts',
],
};

View File

@ -0,0 +1,15 @@
const { join } = require('path');
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
// option from your application's configuration (i.e. project.json).
//
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, 'tailwind.config.js'),
},
autoprefixer: {},
},
};

View File

@ -0,0 +1,9 @@
{
"name": "graph-migrate",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "graph/migrate/src",
"projectType": "library",
"tags": [],
"// targets": "to see all targets run: nx show project graph-migrate --web",
"targets": {}
}

View File

@ -0,0 +1 @@
export * from './lib/migrate';

View File

@ -0,0 +1,96 @@
/* 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 { useSelector } from '@xstate/react';
import {
currentMigrationHasChanges,
currentMigrationHasFailed,
currentMigrationHasSucceeded,
} from '../state/automatic/selectors';
import { MigrationTimeline } from './migration-timeline';
import { Interpreter } from 'xstate';
import type {
AutomaticMigrationEvents,
AutomaticMigrationState,
} from '../state/automatic/types';
export function AutomaticMigration(props: {
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
onRunMigration: (migration: MigrationDetailsWithId) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
) => void;
onViewImplementation: (migration: MigrationDetailsWithId) => void;
onViewDocumentation: (migration: MigrationDetailsWithId) => void;
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
}) {
const currentMigration = useSelector(
props.actor,
(state) => state.context.currentMigration
);
const currentMigrationIndex = props.migrations.findIndex(
(migration) => migration.id === currentMigration?.id
);
const currentMigrationRunning = useSelector(
props.actor,
(state) => state.context.currentMigrationRunning
);
const currentMigrationFailed = useSelector(props.actor, (state) =>
currentMigrationHasFailed(state.context)
);
const currentMigrationSuccess = useSelector(props.actor, (state) =>
currentMigrationHasSucceeded(state.context)
);
const currentMigrationChanges = useSelector(props.actor, (state) =>
currentMigrationHasChanges(state.context)
);
const isDone = useSelector(props.actor, (state) => state.matches('done'));
const isInit = useSelector(props.actor, (state) => state.matches('init'));
const handleReviewMigration = (migrationId: string) => {
props.actor.send({
type: 'reviewMigration',
migrationId,
});
};
return (
<MigrationTimeline
migrations={props.migrations}
nxConsoleMetadata={props.nxConsoleMetadata}
currentMigrationIndex={
currentMigrationIndex >= 0 ? currentMigrationIndex : 0
}
currentMigrationRunning={currentMigrationRunning}
currentMigrationFailed={currentMigrationFailed}
currentMigrationSuccess={currentMigrationSuccess}
currentMigrationHasChanges={currentMigrationChanges}
isDone={isDone}
isInit={isInit}
onRunMigration={props.onRunMigration}
onSkipMigration={props.onSkipMigration}
onFileClick={props.onFileClick}
onViewImplementation={props.onViewImplementation}
onViewDocumentation={props.onViewDocumentation}
onReviewMigration={handleReviewMigration}
/>
);
}

View File

@ -0,0 +1,7 @@
export * from './automatic-migration';
export * from './migration-card';
export * from './migration-list';
export * from './migration-done';
export * from './migration-init';
export * from './migration-settings-panel';
export * from './migration-timeline';

View File

@ -0,0 +1,279 @@
/* 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 } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
export interface MigrationCardHandle {
expand: () => void;
collapse: () => void;
toggle: () => void;
}
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>
<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);
}}
>
{file.path}
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
});

View File

@ -0,0 +1,144 @@
import { motion } from 'framer-motion';
import {
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
import { useState } from 'react';
import { Popover } from '@nx/graph/ui-common';
import { PrimaryAction } from '../migrate';
export interface MigrationDoneProps {
onCancel: () => void;
onFinish: (squash: boolean) => void;
shouldSquashCommits: boolean;
}
export function MigrationDone({
onCancel,
onFinish,
shouldSquashCommits,
}: MigrationDoneProps) {
const [isOpen, setIsOpen] = useState(false);
const [squashCommits, setSquashCommits] = useState(shouldSquashCommits);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="my-10 flex flex-col items-center justify-center gap-4"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 10,
delay: 0.3,
}}
className="flex h-16 w-16 items-center justify-center text-6xl"
>
<span role="img" aria-label="checkmark">
<CheckCircleIcon className="h-12 w-12 text-green-700 dark:text-green-400" />
</span>
</motion.div>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="rounded-md border border-green-500/30 bg-green-50 px-6 py-5 text-green-700 shadow-lg dark:border-green-900/30 dark:bg-green-900/10 dark:text-green-500"
>
<h2 className="flex items-center gap-3 text-xl font-bold">
All migrations completed
</h2>
</motion.div>
<div className="mt-6 flex items-center justify-center gap-4">
<motion.button
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
onClick={onCancel}
className="flex w-full items-center rounded-md border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
Cancel
</motion.button>
<div className="flex">
<motion.button
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
onClick={() => onFinish(squashCommits)}
type="button"
title={
squashCommits
? PrimaryAction.FinishSquashingCommits
: PrimaryAction.FinishWithoutSquashingCommits
}
className="whitespace-nowrap rounded-l-md border border-blue-700 bg-blue-500 px-6 py-3 text-sm font-medium text-white shadow-sm hover:bg-blue-600 dark:border-blue-700 dark:bg-blue-600 dark:text-white hover:dark:bg-blue-700"
>
{squashCommits
? PrimaryAction.FinishSquashingCommits
: PrimaryAction.FinishWithoutSquashingCommits}
</motion.button>
<div className="relative flex">
<motion.button
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="border-l-1 flex items-center rounded-r-md border border-blue-700 bg-blue-500 px-2 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 dark:border-blue-700 dark:bg-blue-700 dark:text-white hover:dark:bg-blue-800"
>
<ChevronDownIcon className="h-4 w-4" />
</motion.button>
<Popover
isOpen={isOpen}
onClose={() => setIsOpen(false)}
position={{
left: '-2rem',
top: '-6.75rem',
}}
>
<ul className="p-2">
<li
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setSquashCommits(true);
setIsOpen(false);
}}
>
<span
className={squashCommits ? 'inline-block' : 'opacity-0'}
>
<CheckIcon className="h-4 w-4" />
</span>
<span>Squash commits</span>
</li>
<li
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setSquashCommits(false);
setIsOpen(false);
}}
>
<span
className={!squashCommits ? 'inline-block' : 'opacity-0'}
>
<CheckIcon className="h-4 w-4" />
</span>
<span>Do not squash commits</span>
</li>
</ul>
</Popover>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,55 @@
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import { motion } from 'framer-motion';
export function MigrationInit({ onStart }: { onStart: () => void }) {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="flex h-full flex-col items-center justify-center gap-6 px-6 py-12 text-center"
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.2 }}
className="text-6xl"
>
<span role="img" aria-label="tools">
<WrenchScrewdriverIcon className="h-12 w-12" />
</span>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
className="text-2xl font-semibold text-gray-800 dark:text-white"
>
Ready to Migrate
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.6 }}
className="max-w-xl text-base text-gray-600 dark:text-gray-400"
>
Welcome to the Migrate UI. This tool will guide you through updating
your workspace. Click the button below to start running migrations.
</motion.p>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
onClick={onStart}
className="mt-4 rounded-md bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-md transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Start Migration
</motion.button>
</motion.div>
);
}

View File

@ -0,0 +1,161 @@
/* eslint-disable @nx/enforce-module-boundaries */
import { FileChange } from '@nx/devkit';
import { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
/* eslint-enable @nx/enforce-module-boundaries */
import { PlayIcon } from '@heroicons/react/24/outline';
import { useCallback, useMemo, useState } from 'react';
import { MigrationCard } from './migration-card';
export function MigrationList(props: {
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
onRunMigration: (migration: MigrationDetailsWithId) => void;
onRunMany: (migrations: MigrationDetailsWithId[]) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
) => void;
onViewImplementation: (migration: MigrationDetailsWithId) => void;
onViewDocumentation: (migration: MigrationDetailsWithId) => void;
}) {
const [selectedMigrations, setSelectedMigrations] = useState<
Record<string, boolean>
>(
props.migrations.reduce((acc, migration) => {
acc[migration.id] = false;
return acc;
}, {} as Record<string, boolean>)
);
const numberSelected = useMemo(
() =>
Object.values(selectedMigrations).filter((selected) => selected).length,
[selectedMigrations]
);
const anySelected = useMemo(
() =>
Object.values(selectedMigrations).filter((selected) => selected).length >
0,
[selectedMigrations]
);
const allSelected = useMemo(
() => props.migrations.length === numberSelected,
[props.migrations, numberSelected]
);
const numberFailed = useMemo(
() =>
Object.values(props.nxConsoleMetadata.completedMigrations ?? {}).filter(
(migration) => migration.type === 'failed'
).length,
[props.nxConsoleMetadata.completedMigrations]
);
const handleHeaderCheckboxClick = () => {
const newSelectedState = !anySelected;
setSelectedMigrations(
Object.keys(selectedMigrations).reduce((acc, migrationId) => {
acc[migrationId] = newSelectedState;
return acc;
}, {} as Record<string, boolean>)
);
};
const selectAllCheckboxRef = useCallback(
(el: HTMLInputElement | null) => {
if (!el) return;
el.checked = allSelected;
el.indeterminate = anySelected && !allSelected;
},
[allSelected, anySelected]
);
const handleRunMany = () => {
props.onRunMany(
props.migrations.filter((migration) => selectedMigrations[migration.id])
);
};
const handleRerunFailed = () => {
props.onRunMany(
props.migrations.filter(
(migration) =>
props.nxConsoleMetadata.completedMigrations?.[migration.id]?.type ===
'failed'
)
);
};
return (
<>
<div
className={`my-2 gap-2 rounded-md border border-slate-200 p-2 dark:border-slate-700/60`}
>
<div className="flex h-4 w-full items-center gap-4">
<input
ref={selectAllCheckboxRef}
onClick={handleHeaderCheckboxClick}
id="select-all"
name="select-all"
value="select-all"
type="checkbox"
className={`h-4 w-4 accent-blue-500 dark:accent-sky-500`}
/>
<label htmlFor="select-all">
{allSelected || anySelected
? `${numberSelected} selected`
: 'Select all'}
</label>
{anySelected && (
<button
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-2 py-0.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={handleRunMany}
>
<PlayIcon className="h-5 w-5"></PlayIcon>
Run selected migrations
</button>
)}
{numberFailed > 0 && (
<button
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-2 py-0.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={handleRerunFailed}
>
<PlayIcon className="h-5 w-5"></PlayIcon>
Rerun failed migrations
</button>
)}
</div>
</div>
<div>
{props.migrations.map((migration) => (
<MigrationCard
key={migration.id}
migration={migration}
nxConsoleMetadata={props.nxConsoleMetadata}
isSelected={selectedMigrations[migration.id]}
onSelect={(isSelected) =>
setSelectedMigrations({
...selectedMigrations,
[migration.id]: isSelected,
})
}
onRunMigration={() => props.onRunMigration(migration)}
onViewImplementation={() => {
props.onViewImplementation(migration);
}}
onViewDocumentation={() => {
props.onViewDocumentation(migration);
}}
onFileClick={(file) => {
props.onFileClick(migration, file);
}}
/>
))}
</div>
</>
);
}

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import { Popover } from '@nx/graph/ui-common';
export interface MigrationSettingsPanelProps {
createCommits: boolean;
setCreateCommits: (createCommits: boolean) => void;
commitPrefix: string;
setCommitPrefix: (commitPrefix: string) => void;
}
export function MigrationSettingsPanel({
createCommits,
setCreateCommits,
commitPrefix,
setCommitPrefix,
}: MigrationSettingsPanelProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex w-full items-center 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 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
Settings
</button>
<Popover
isOpen={isOpen}
onClose={() => setIsOpen(false)}
position={{ left: '-12rem', top: '2.75rem' }}
>
<div className="p-4">
<div className="flex items-center gap-2 py-2">
<input
checked={createCommits}
onChange={(e) => setCreateCommits(e.target.checked)}
id="create-commits"
name="create-commits"
value="create-commits"
type="checkbox"
className="h-4 w-4"
/>
<label htmlFor="create-commits">Create commits</label>
</div>
<div className="mb-2 border-b border-slate-200/25 dark:border-slate-700/25"></div>
<div className="flex flex-col gap-2">
<label htmlFor="commit-prefix">Commit prefix</label>
<input
type="text"
placeholder="chore: [nx migration] "
className="block w-full flex-1 rounded-md border border-slate-300/[0.25] bg-white p-1.5 font-light placeholder:font-light placeholder:text-slate-400 dark:border-slate-900 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700"
data-cy="textFilterInput"
name="filter"
onChange={(event) => setCommitPrefix(event.currentTarget.value)}
value={commitPrefix}
/>
</div>
</div>
</Popover>
</div>
);
}

View File

@ -0,0 +1,564 @@
/* 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>
);
}

View File

@ -0,0 +1,236 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MigrateUI } from './migrate';
const meta: Meta<typeof MigrateUI> = {
component: MigrateUI,
title: 'MigrateUI',
};
export default meta;
type Story = StoryObj<typeof MigrateUI>;
export const Automatic: Story = {
args: {
migrations: [
{
id: 'migration-1',
name: 'migration-1',
description: 'This is a migration that does a thing labeled with one.',
version: '1.0.0',
package: 'nx',
implementation: './src/migrations/migration-1.ts',
},
{
id: 'migration-2',
name: 'migration-2',
description:
'Funnily, this is another migration that does a thing labeled with two.',
version: '1.0.1',
package: '@nx/react',
implementation: './src/migrations/migration-2.ts',
},
{
id: 'migration-3',
name: 'migration-3',
description:
'This is a migration that does a thing labeled with three.',
version: '1.0.1',
package: '@nx/js',
implementation: './src/migrations/migration-3.ts',
},
{
id: 'migration-4',
name: 'migration-4',
description: 'This is a migration that does a thing labeled with four.',
version: '1.0.2',
package: 'nx',
implementation: './src/migrations/migration-4.ts',
},
{
id: 'migration-3-1',
name: 'migration-3',
description:
'This is a migration that does a thing labeled with three.',
version: '1.0.1',
package: '@nx/js',
implementation: './src/migrations/migration-3.ts',
},
{
id: 'migration-6',
name: 'migration-6',
description: 'This migration performs updates labeled as number six.',
version: '1.0.3',
package: '@nx/workspace',
implementation: './src/migrations/migration-6.ts',
},
{
id: 'migration-7',
name: 'migration-7',
description:
'Lucky number seven migration that updates configurations.',
version: '1.0.3',
package: '@nx/devkit',
implementation: './src/migrations/migration-7.ts',
},
],
nxConsoleMetadata: {
completedMigrations: {
'migration-1': {
name: 'migration-1',
type: 'successful',
changedFiles: [],
ref: '123',
},
'migration-2': {
type: 'skipped',
},
'migration-3': {
name: 'migration-3',
type: 'failed',
error: 'This is an error',
},
},
targetVersion: '20.3.2',
},
onFinish: (squash: boolean) => {
console.log('finished', squash);
},
},
};
export const AllCompleted: Story = {
args: {
migrations: [
{
id: 'migration-1',
name: 'migration-1',
description: 'This is a migration that does a thing labeled with one.',
version: '1.0.0',
package: 'nx',
implementation: './src/migrations/migration-1.ts',
},
{
id: 'migration-2',
name: 'migration-2',
description:
'Funnily, this is another migration that does a thing labeled with two.',
version: '1.0.1',
package: '@nx/react',
implementation: './src/migrations/migration-2.ts',
},
{
id: 'migration-3',
name: 'migration-3',
description:
'This is a migration that does a thing labeled with three.',
version: '1.0.1',
package: '@nx/js',
implementation: './src/migrations/migration-3.ts',
},
{
id: 'migration-4',
name: 'migration-4',
description: 'This is a migration that does a thing labeled with four.',
version: '1.0.2',
package: 'nx',
implementation: './src/migrations/migration-4.ts',
},
{
id: 'migration-3-1',
name: 'migration-3',
description:
'This is a migration that does a thing labeled with three.',
version: '1.0.1',
package: '@nx/js',
implementation: './src/migrations/migration-3.ts',
},
{
id: 'migration-6',
name: 'migration-6',
description: 'This migration performs updates labeled as number six.',
version: '1.0.3',
package: '@nx/workspace',
implementation: './src/migrations/migration-6.ts',
},
{
id: 'migration-7',
name: 'migration-7',
description:
'Lucky number seven migration that updates configurations.',
version: '1.0.3',
package: '@nx/devkit',
implementation: './src/migrations/migration-7.ts',
},
],
nxConsoleMetadata: {
completedMigrations: {
'migration-1': {
name: 'migration-1',
type: 'successful',
changedFiles: [],
ref: '123',
},
'migration-2': {
name: 'migration-2',
type: 'successful',
changedFiles: [],
ref: '124',
},
'migration-3': {
name: 'migration-3',
type: 'successful',
changedFiles: [],
ref: '125',
},
'migration-4': {
name: 'migration-4',
type: 'successful',
changedFiles: [],
ref: '126',
},
'migration-3-1': {
name: 'migration-3',
type: 'successful',
changedFiles: [],
ref: '127',
},
'migration-6': {
name: 'migration-6',
type: 'successful',
changedFiles: [],
ref: '128',
},
'migration-7': {
name: 'migration-7',
type: 'successful',
changedFiles: [],
ref: '129',
},
},
targetVersion: '20.3.2',
},
onRunMigration: (migration) => {
console.log('run migration', migration);
},
onRunMany: (migrations, configuration) => {
console.log('run many migrations', migrations, configuration);
},
onSkipMigration: (migration) => {
console.log('skip migration', migration);
},
onFileClick: (migration, file) => {
console.log('file click', migration, file);
},
onViewImplementation: (migration) => {
console.log('view implementation', migration);
},
onViewDocumentation: (migration) => {
console.log('view documentation', migration);
},
onCancel: () => {
console.log('cancel');
},
onFinish: (squash: boolean) => {
console.log('finished', squash);
},
},
};

View File

@ -0,0 +1,319 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
// nx-ignore-next-line
import type { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
// nx-ignore-next-line
import { type FileChange } from 'nx/src/devkit-exports';
/* eslint-enable @nx/enforce-module-boundaries */
import { useEffect, useState } from 'react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { Popover } from '@nx/graph/ui-common';
import { useInterpret, useSelector } from '@xstate/react';
import { machine as automaticMigrationMachine } from './state/automatic/machine';
import {
MigrationInit,
MigrationDone,
MigrationSettingsPanel,
AutomaticMigration,
} from './components';
export interface MigrateUIProps {
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
onRunMigration: (
migration: MigrationDetailsWithId,
configuration: {
createCommits: boolean;
}
) => void;
onRunMany: (
migrations: MigrationDetailsWithId[],
configuration: {
createCommits: boolean;
commitPrefix: string;
}
) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onCancel: () => void;
onFinish: (squashCommits: boolean) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
) => void;
onViewImplementation: (migration: MigrationDetailsWithId) => void;
onViewDocumentation: (migration: MigrationDetailsWithId) => void;
}
export enum PrimaryAction {
RunMigrations = 'Run Migrations',
PauseMigrations = 'Pause Migrations',
FinishWithoutSquashingCommits = 'Finish without squashing commits',
FinishSquashingCommits = 'Finish (squash commits)',
}
export function MigrateUI(props: MigrateUIProps) {
const [createCommits, setCreateCommits] = useState(true);
const [commitPrefix, setCommitPrefix] = useState('');
const [primaryAction, setPrimaryAction] = useState<PrimaryAction>(
PrimaryAction.RunMigrations
);
// For popover
const [isOpen, setIsOpen] = useState(false);
const actor = useInterpret(automaticMigrationMachine, {
actions: {
runMigration: (ctx) => {
console.log('runMigration', ctx.currentMigration);
if (ctx.currentMigration) {
props.onRunMigration(ctx.currentMigration, { createCommits });
}
},
},
});
const isDone = useSelector(actor, (state) => state.matches('done'));
const isInit = useSelector(actor, (state) => state.matches('init'));
const running = useSelector(actor, (state) => state.matches('running'));
useEffect(() => {
actor.send({
type: 'loadInitialData',
migrations: props.migrations,
metadata: props.nxConsoleMetadata,
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- only load initial data when migrations change
}, [JSON.stringify(props.migrations)]);
useEffect(() => {
actor.send({
type: 'updateMetadata',
metadata: props.nxConsoleMetadata,
});
}, [props.nxConsoleMetadata, actor]);
useEffect(() => {
if (isDone) {
setPrimaryAction(PrimaryAction.FinishSquashingCommits);
}
}, [isDone, primaryAction]);
useEffect(() => {
if (
(primaryAction === PrimaryAction.RunMigrations ||
primaryAction === PrimaryAction.PauseMigrations) &&
!isInit
) {
setPrimaryAction(
running ? PrimaryAction.PauseMigrations : PrimaryAction.RunMigrations
);
}
}, [running, primaryAction, isInit]);
const handlePauseResume = () => {
if (running) {
actor.send({ type: 'pause' });
} else {
actor.send({ type: 'startRunning' });
}
};
const handlePrimaryActionSelection = () => {
if (
primaryAction === PrimaryAction.RunMigrations ||
primaryAction === PrimaryAction.PauseMigrations
) {
handlePauseResume();
} else if (
primaryAction === PrimaryAction.FinishWithoutSquashingCommits ||
primaryAction === PrimaryAction.FinishSquashingCommits
) {
props.onFinish(primaryAction === PrimaryAction.FinishSquashingCommits);
}
};
if (isInit) {
return (
<MigrationInit onStart={() => actor.send({ type: 'startRunning' })} />
);
}
if (isDone) {
return (
<MigrationDone
onCancel={props.onCancel}
onFinish={props.onFinish}
shouldSquashCommits={createCommits}
/>
);
}
return (
<div className="flex h-screen flex-col overflow-hidden p-2">
{/* Page Header */}
<div className="flex shrink-0 items-center justify-between pb-4">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold">
Migrating to {props.nxConsoleMetadata.targetVersion}
</h2>
{/* Migration Controls */}
</div>
{
<MigrationSettingsPanel
createCommits={createCommits}
setCreateCommits={setCreateCommits}
commitPrefix={commitPrefix}
setCommitPrefix={setCommitPrefix}
/>
}
</div>
<div className="flex-1 overflow-y-auto">
<AutomaticMigration
actor={actor}
migrations={props.migrations}
nxConsoleMetadata={props.nxConsoleMetadata}
onRunMigration={(migration) =>
props.onRunMigration(migration, { createCommits })
}
onSkipMigration={(migration) => props.onSkipMigration(migration)}
onViewImplementation={(migration) =>
props.onViewImplementation(migration)
}
onViewDocumentation={(migration) =>
props.onViewDocumentation(migration)
}
onFileClick={props.onFileClick}
/>
</div>
<div className="bottom-0 flex shrink-0 justify-end gap-2 bg-transparent py-4">
<div className="flex gap-2">
<button
onClick={props.onCancel}
type="button"
className="flex w-full items-center 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 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
Cancel
</button>
<div className="flex">
<button
onClick={handlePrimaryActionSelection}
type="button"
title="Finish"
className="whitespace-nowrap rounded-l-md border border-blue-700 bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 dark:border-blue-700 dark:bg-blue-600 dark:text-white hover:dark:bg-blue-700"
>
{primaryAction}
</button>
<div className="relative flex">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="border-l-1 flex items-center rounded-r-md border border-blue-700 bg-blue-500 px-2 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 dark:border-blue-700 dark:bg-blue-700 dark:text-white hover:dark:bg-blue-800"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<Popover
isOpen={isOpen}
onClose={() => setIsOpen(false)}
position={{
left: '-14rem',
top: isDone ? '2.75rem' : '-9.75rem',
}}
>
<ul className="p-2">
{!isDone && (
<>
{' '}
{!running && (
<li
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setPrimaryAction(PrimaryAction.RunMigrations);
setIsOpen(false);
}}
>
<span
className={
primaryAction === PrimaryAction.RunMigrations
? 'inline-block'
: 'opacity-0'
}
>
<CheckIcon className="h-4 w-4" />
</span>
<span>{'Run Migrations'}</span>
</li>
)}
{running && (
<li
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setPrimaryAction(PrimaryAction.PauseMigrations);
setIsOpen(false);
}}
>
<span
className={
primaryAction === PrimaryAction.PauseMigrations
? 'inline-block'
: 'opacity-0'
}
>
<CheckIcon className="h-4 w-4" />
</span>
<span>{'Pause Migrations'}</span>
</li>
)}
<div className="my-1 h-0.5 w-full bg-slate-300/30" />
</>
)}
<li
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setPrimaryAction(PrimaryAction.FinishSquashingCommits);
setIsOpen(false);
}}
>
<span
className={
primaryAction === PrimaryAction.FinishSquashingCommits
? 'inline-block'
: 'opacity-0'
}
>
<CheckIcon className="h-4 w-4" />
</span>
<span>Squash commits</span>
</li>
<li
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setPrimaryAction(
PrimaryAction.FinishWithoutSquashingCommits
);
setIsOpen(false);
}}
>
<span
className={
primaryAction ===
PrimaryAction.FinishWithoutSquashingCommits
? 'inline-block'
: 'opacity-0'
}
>
<CheckIcon className="h-4 w-4" />
</span>
<span>Do not squash commits</span>
</li>
</ul>
</Popover>
</div>
</div>
</div>
</div>
</div>
);
}
export default MigrateUI;

View File

@ -0,0 +1,49 @@
import { AutomaticMigrationState } from './types';
import {
currentMigrationCanLeaveReview,
currentMigrationHasChanges,
currentMigrationHasFailed,
currentMigrationIsSkipped,
} from './selectors';
export const guards = {
canStartRunningCurrentMigration: (ctx: AutomaticMigrationState) => {
return (
!!ctx.currentMigration &&
!ctx.currentMigrationRunning &&
((!currentMigrationHasFailed(ctx) && !currentMigrationHasChanges(ctx)) ||
currentMigrationIsSkipped(ctx))
);
},
currentMigrationIsDone: (ctx: AutomaticMigrationState) => {
if (!ctx.currentMigration) {
return false;
}
return (
!!ctx.currentMigration &&
!ctx.currentMigrationRunning &&
currentMigrationCanLeaveReview(ctx)
);
},
currentMigrationCanLeaveReview: (ctx: AutomaticMigrationState) =>
currentMigrationCanLeaveReview(ctx),
lastMigrationIsDone: (ctx: AutomaticMigrationState) => {
if (!ctx.migrations) {
return false;
}
const currentMigrationIndex = ctx.migrations.findIndex(
(migration) => migration.id === ctx.currentMigration?.id
);
return (
currentMigrationIndex === ctx.migrations.length - 1 &&
currentMigrationCanLeaveReview(ctx)
);
},
needsReview: (ctx: AutomaticMigrationState) => {
return (
!ctx.currentMigrationRunning &&
!guards.canStartRunningCurrentMigration(ctx)
);
},
};

View File

@ -0,0 +1,365 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
// nx-ignore-next-line
import {
addFailedMigration,
addSkippedMigration,
addSuccessfulMigration,
MigrationsJsonMetadata,
} from 'nx/src/command-line/migrate/migrate-ui-api';
/* eslint-enable @nx/enforce-module-boundaries */
import { interpret } from 'xstate';
import { machine } from './machine';
const dummyMigrations: MigrationDetailsWithId[] = [
{
id: 'migration-1',
name: 'migration-1',
description: 'This is a migration that does a thing labeled with one.',
version: '1.0.0',
package: 'nx',
implementation: '/path/to/migration-1',
},
{
id: 'migration-2',
name: 'migration-2',
description:
'Funnily, this is another migration that does a thing labeled with two.',
version: '1.0.1',
package: '@nx/react',
implementation: '/path/to/migration-2',
},
{
id: 'migration-3',
name: 'migration-3',
description: 'This is a migration that does a thing labeled with three.',
version: '1.0.1',
package: '@nx/js',
implementation: '/path/to/migration-3',
},
{
id: 'migration-4',
name: 'migration-4',
description: 'This is a migration that does a thing labeled with four.',
version: '1.0.2',
package: 'nx',
implementation: '/path/to/migration-4',
},
{
id: 'migration-3-1',
name: 'migration-3',
description: 'This is a migration that does a thing labeled with three.',
version: '1.0.1',
package: '@nx/js',
implementation: '/path/to/migration-3',
},
{
id: 'migration-6',
name: 'migration-6',
description: 'This migration performs updates labeled as number six.',
version: '1.0.3',
package: '@nx/workspace',
implementation: '/path/to/migration-6',
},
{
id: 'migration-7',
name: 'migration-7',
description: 'Lucky number seven migration that updates configurations.',
version: '1.0.3',
package: '@nx/devkit',
implementation: '/path/to/migration-7',
},
];
describe('Automatic Migration Machine', () => {
it('should start in init state', () => {
const service = interpret(machine);
service.start();
expect(service.getSnapshot().value).toBe('init');
service.stop();
});
it('should keep running migrations until one fails', async () => {
let metadata: MigrationsJsonMetadata = {
targetVersion: '20.3.2',
};
const service = interpret(
machine.withConfig({
actions: {
runMigration: (ctx) => {
const migration = ctx.currentMigration;
if (!migration) {
return;
}
console.log('running migration', migration.id);
if (migration.id !== 'migration-3') {
metadata = addSuccessfulMigration(
migration.id,
[],
'commit-123'
)(metadata);
} else {
metadata = addFailedMigration(migration.id, 'error')(metadata);
}
service.send({
type: 'updateMetadata',
metadata,
});
},
},
})
);
service.start();
service.send({
type: 'loadInitialData',
migrations: dummyMigrations,
metadata: metadata,
});
service.send('startRunning');
expect(service.getSnapshot().value).toBe('needsReview');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-3'
);
service.stop();
});
it('should continue running migrations after failed one is skipped', async () => {
let metadata: MigrationsJsonMetadata = {
targetVersion: '20.3.2',
};
const service = interpret(
machine.withConfig({
actions: {
runMigration: (ctx) => {
const migration = ctx.currentMigration;
if (!migration) {
return;
}
console.log('running migration', migration.id);
if (migration.id !== 'migration-3') {
metadata = addSuccessfulMigration(
migration.id,
[],
'commit-123'
)(metadata);
} else {
metadata = addFailedMigration(migration.id, 'error')(metadata);
}
service.send({
type: 'updateMetadata',
metadata,
});
},
},
})
);
service.start();
service.send({
type: 'loadInitialData',
migrations: dummyMigrations,
metadata: metadata,
});
service.send('startRunning');
expect(service.getSnapshot().value).toBe('needsReview');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-3'
);
metadata = addSkippedMigration('migration-3')(metadata);
service.send({
type: 'updateMetadata',
metadata,
});
expect(service.getSnapshot().value).toBe('done');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-7'
);
service.stop();
});
it('should keep running migrations until one has changes', async () => {
let metadata: MigrationsJsonMetadata = {
targetVersion: '20.3.2',
};
const service = interpret(
machine.withConfig({
actions: {
runMigration: (ctx) => {
const migration = ctx.currentMigration;
if (!migration) {
return;
}
console.log('running migration', migration.id);
if (migration.id !== 'migration-3') {
metadata = addSuccessfulMigration(
migration.id,
[],
'commit-123'
)(metadata);
} else {
metadata = addSuccessfulMigration(
migration.id,
[
{
type: 'UPDATE',
path: 'apps/app/tsconfig.json',
},
],
'commit-123'
)(metadata);
}
service.send({
type: 'updateMetadata',
metadata,
});
},
},
})
);
service.start();
service.send({
type: 'loadInitialData',
migrations: dummyMigrations,
metadata: metadata,
});
service.send('startRunning');
expect(service.getSnapshot().value).toBe('needsReview');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-3'
);
service.stop();
});
it('should keep running migrations after changes are reviewed', async () => {
let metadata: MigrationsJsonMetadata = {
targetVersion: '20.3.2',
};
const service = interpret(
machine.withConfig({
actions: {
runMigration: (ctx) => {
const migration = ctx.currentMigration;
if (!migration) {
return;
}
console.log('running migration', migration.id);
if (migration.id !== 'migration-3') {
metadata = addSuccessfulMigration(
migration.id,
[],
'commit-123'
)(metadata);
} else {
metadata = addSuccessfulMigration(
migration.id,
[
{
type: 'UPDATE',
path: 'apps/app/tsconfig.json',
},
],
'commit-123'
)(metadata);
}
service.send({
type: 'updateMetadata',
metadata,
});
},
},
})
);
service.start();
service.send({
type: 'loadInitialData',
migrations: dummyMigrations,
metadata: metadata,
});
service.send('startRunning');
expect(service.getSnapshot().value).toBe('needsReview');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-3'
);
service.send({
type: 'reviewMigration',
migrationId: 'migration-3',
});
expect(service.getSnapshot().value).toBe('done');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-7'
);
service.stop();
});
it('should not continue running migrations after failed one is skipped if state is paused', async () => {
let metadata: MigrationsJsonMetadata = {
targetVersion: '20.3.2',
};
const service = interpret(
machine.withConfig({
actions: {
runMigration: (ctx) => {
const migration = ctx.currentMigration;
if (!migration) {
return;
}
console.log('running migration', migration.id);
if (migration.id !== 'migration-3') {
metadata = addSuccessfulMigration(
migration.id,
[],
'commit-123'
)(metadata);
} else {
metadata = addFailedMigration(migration.id, 'error')(metadata);
}
service.send({
type: 'updateMetadata',
metadata,
});
},
},
})
);
service.start();
service.send({
type: 'loadInitialData',
migrations: dummyMigrations,
metadata: metadata,
});
service.send('startRunning');
expect(service.getSnapshot().value).toBe('needsReview');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-3'
);
service.send('pause');
metadata = addSkippedMigration('migration-3')(metadata);
service.send({
type: 'updateMetadata',
metadata,
});
expect(service.getSnapshot().value).toBe('paused');
expect(service.getSnapshot().context.currentMigration?.id).toEqual(
'migration-3'
);
service.stop();
});
});

View File

@ -0,0 +1,157 @@
import { assign } from '@xstate/immer';
import { createMachine } from 'xstate';
import { guards } from './guards';
import type {
AutomaticMigrationEvents,
AutomaticMigrationState,
} from './types';
import { findFirstIncompleteMigration } from './selectors';
export const machine = createMachine<
AutomaticMigrationState,
AutomaticMigrationEvents
>(
{
/** @xstate-layout N4IgpgJg5mDOIC5QFsCWUBOBDALmAxADYD2WEAkgHao6paEAiuWA2gAwC6ioADsbDVTFK3EAA9EbAHQAmAIwAOBXLkB2AJyqALADYtbLTIA0IAJ6IdO9VJ0GAzGx0yZOgKwGAvh5NpMuAgCuPBD+ALJgOGTM7FxIIHwCtMKiEgiKOlJauuqu6mzqCqquyibmCG4ZMppsMmxsdupy6k5ePujYeFI8WAGwkPiwkRg4AEoBlNSUUDGiCYLJcalyupmqVa6ubjLKdtqliHIyNgquLpZshzraWnatIL4dYFIY45NQ+N29YDNxc0kiiwOqjkUk2qjsWjUkMsy32aQUdlkWRceS06js2wxdwe-mer1QU3wP14-HmANAqWKINUOgUVS0SkU+nUcO0oI0lgxllcWTU2PauJeEwJ7xYcliJMSQnJ4kQm0R8kMtg2+RkuzhyxBclcTVcawMbFcENU-L8nSFbyJMgl8VJ-xScrsiMMevUMlUbGBemMZkQLkRbo2BQ2Gl0rlNjzxwsJLDsNr+0odCFy1jq2jkTlpCNUCjhLqk6kLymKVXyWhpEdxlDAkFgIzAADdUGAAO4fHp9Ym2qULCmIAC0y1BhuUarUPIUzVccIcCikRScwLYk6KDRN3nuAs61dr9abraJnFmdsTgIQ7qOwPcziyy9pPrK+mk6Lccgh2rkBgUOi8G8oxAgOBRBxPBjx7GVUn7JxhypMcigZKc4X7NkdF2Nx8mKBDNErTpPj6CAwLJJN+2sKwXCURdilpcs4TdKQMTsJwZB1eoaTpHCngtEVCPtM9GMqZQ6TdHQVEMAxWTnL9tTydQshzdEOKkHcIDrRtmxbHjTz7BA7CaKRs3cXZCyqSw4UM0E1TyXTyLsBQtEUiBhDATTe1lBAdn01RgWcK4GiyLQ8znYp6i0TZJ3cE51F-DwgA */
predictableActionArguments: true,
preserveActionOrder: true,
id: 'migrate',
initial: 'init',
context: {
reviewedMigrations: [],
},
states: {
init: {
on: {
startRunning: [
{
target: 'running',
},
],
},
},
paused: {
on: {
startRunning: [
{
target: 'running',
},
],
},
},
running: {
on: {
pause: [
{
target: 'paused',
},
],
},
always: [
{
cond: 'lastMigrationIsDone',
target: 'done',
},
{
cond: 'currentMigrationIsDone',
target: 'running',
actions: ['incrementCurrentMigration'],
},
{
cond: 'canStartRunningCurrentMigration',
actions: ['setCurrentMigrationRunning', 'runMigration'],
},
{
target: 'needsReview',
cond: 'needsReview',
},
],
},
needsReview: {
on: {
pause: [
{
target: 'paused',
},
],
},
always: [
{
cond: 'currentMigrationCanLeaveReview',
target: 'running',
},
],
},
done: {},
},
on: {
loadInitialData: [
{
actions: [
assign((ctx, event) => {
ctx.migrations = event.migrations;
ctx.nxConsoleMetadata = event.metadata;
ctx.currentMigration = findFirstIncompleteMigration(
event.migrations,
event.metadata
);
}),
],
},
],
updateMetadata: [
{
actions: [
assign((ctx, event) => {
ctx.nxConsoleMetadata = event.metadata;
if (
ctx.currentMigration &&
ctx.nxConsoleMetadata.completedMigrations?.[
ctx.currentMigration.id
]
) {
ctx.currentMigrationRunning = false;
}
}),
],
},
],
reviewMigration: [
{
actions: [
assign((ctx, event) => {
ctx.reviewedMigrations = [
...ctx.reviewedMigrations,
event.migrationId,
];
}),
],
},
],
},
},
{
guards,
actions: {
incrementCurrentMigration: assign((ctx) => {
if (!ctx.migrations) {
return;
}
const currentMigrationIndex = ctx.migrations?.findIndex(
(migration) => migration.id === ctx.currentMigration?.id
);
if (
currentMigrationIndex === undefined ||
currentMigrationIndex === ctx.migrations.length - 1
) {
return;
}
ctx.currentMigration = ctx.migrations?.[currentMigrationIndex + 1];
}),
setCurrentMigrationRunning: assign((ctx) => {
ctx.currentMigrationRunning = true;
}),
},
}
);

View File

@ -0,0 +1,78 @@
/* eslint-disable @nx/enforce-module-boundaries */
import { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
import { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
/* eslint-enable @nx/enforce-module-boundaries */
import type { AutomaticMigrationState } from './types';
// this is where the ui should start off on
// we assume completed migrations in the past are reviewed
export function findFirstIncompleteMigration(
migrations: MigrationDetailsWithId[],
nxConsoleMetadata: MigrationsJsonMetadata
) {
return (
migrations.find(
(migration) =>
nxConsoleMetadata.completedMigrations?.[migration.id]?.type !==
'successful' &&
nxConsoleMetadata.completedMigrations?.[migration.id]?.type !==
'skipped'
) ?? migrations[0]
);
}
export function currentMigrationHasSucceeded(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) {
return false;
}
const completedMigration =
ctx.nxConsoleMetadata?.completedMigrations?.[ctx.currentMigration.id];
return completedMigration?.type === 'successful';
}
export function currentMigrationHasChanges(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) {
return false;
}
const completedMigration =
ctx.nxConsoleMetadata?.completedMigrations?.[ctx.currentMigration.id];
return (
completedMigration?.type === 'successful' &&
completedMigration.changedFiles.length > 0
);
}
export function currentMigrationIsSkipped(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) {
return false;
}
const completedMigration =
ctx.nxConsoleMetadata?.completedMigrations?.[ctx.currentMigration.id];
return completedMigration?.type === 'skipped';
}
export function currentMigrationIsReviewed(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) {
return false;
}
return ctx.reviewedMigrations.includes(ctx.currentMigration.id);
}
export function currentMigrationCanLeaveReview(ctx: AutomaticMigrationState) {
return (
currentMigrationIsReviewed(ctx) ||
currentMigrationIsSkipped(ctx) ||
(currentMigrationHasSucceeded(ctx) && !currentMigrationHasChanges(ctx))
);
}
export function currentMigrationHasFailed(
ctx: AutomaticMigrationState
): boolean {
if (!ctx.currentMigration) {
return false;
}
const completedMigration =
ctx.nxConsoleMetadata?.completedMigrations?.[ctx.currentMigration.id];
return completedMigration?.type === 'failed';
}

View File

@ -0,0 +1,33 @@
/* eslint-disable @nx/enforce-module-boundaries */
import { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate-ui-api';
import { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
/* eslint-enable @nx/enforce-module-boundaries */
export type AutomaticMigrationState = {
migrations?: MigrationDetailsWithId[];
nxConsoleMetadata?: MigrationsJsonMetadata;
currentMigration?: MigrationDetailsWithId;
currentMigrationRunning?: boolean;
reviewedMigrations: string[];
};
export type AutomaticMigrationEvents =
| {
type: 'loadInitialData';
migrations: MigrationDetailsWithId[];
metadata: MigrationsJsonMetadata;
}
| {
type: 'updateMetadata';
metadata: MigrationsJsonMetadata;
}
| {
type: 'pause';
}
| {
type: 'startRunning';
}
| {
type: 'reviewMigration';
migrationId: string;
};

View File

@ -0,0 +1,20 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
const { createGlobPatternsForDependencies } = require('@nx/react/tailwind');
/* eslint-enable @nx/enforce-module-boundaries */
const { join } = require('path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
join(
__dirname,
'{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}'
),
...createGlobPatternsForDependencies(__dirname),
],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.storybook.json"
}
],
"extends": "../../tsconfig.base.json"
}

View File

@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
],
"lib": ["DOM"]
},
"exclude": [
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,31 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": ""
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.jsx",
"src/**/*.test.js"
],
"include": [
"src/**/*.stories.ts",
"src/**/*.stories.js",
"src/**/*.stories.jsx",
"src/**/*.stories.tsx",
"src/**/*.stories.mdx",
".storybook/*.js",
".storybook/*.ts"
],
"files": [
"../../node_modules/@nx/react/typings/styled-jsx.d.ts",
"../../node_modules/@nx/react/typings/cssmodule.d.ts",
"../../node_modules/@nx/react/typings/image.d.ts"
]
}

View File

@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,3 @@
# ui-common
This library was generated with [Nx](https://nx.dev).

View File

@ -0,0 +1,9 @@
{
"name": "ui-common",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "graph/ui-common/src",
"projectType": "library",
"tags": [],
"// targets": "to see all targets run: nx show project ui-common --web",
"targets": {}
}

View File

@ -0,0 +1,3 @@
export * from './lib/collapsible';
export * from './lib/popover';
export * from './lib/button';

View File

@ -0,0 +1,31 @@
import { ReactNode } from 'react';
interface ButtonProps {
onClick: () => void;
children: ReactNode;
disabled?: boolean;
className?: string;
}
export function Button({
children,
onClick,
disabled,
className = '',
}: ButtonProps) {
return (
<div>
<button
onClick={onClick}
disabled={disabled}
className={`rounded-lg px-4 py-2 font-semibold transition ${
disabled
? 'cursor-not-allowed bg-gray-400'
: 'bg-blue-500 text-white hover:bg-blue-600'
} ${className}`}
>
{children}
</button>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { motion, AnimatePresence } from 'framer-motion';
import { ReactNode } from 'react';
interface CollapsibleProps {
isOpen: boolean;
children: ReactNode;
className?: string;
}
export function Collapsible({ isOpen, children, className }: CollapsibleProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
className={`overflow-hidden ${className}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,33 @@
import { useFloating, useDismiss, useInteractions } from '@floating-ui/react';
export interface PopoverProps {
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
position?: { top?: string; left?: string; right?: string; bottom?: string };
}
export function Popover({ isOpen, onClose, children, position }: PopoverProps) {
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: (open) => {
if (!open) onClose();
},
});
const dismiss = useDismiss(context, { referencePress: false });
const { getFloatingProps } = useInteractions([dismiss]);
if (!isOpen) return null;
return (
<div
ref={refs.setFloating}
style={{ ...floatingStyles, ...position }}
{...getFloatingProps()}
className="animate-fadeIn absolute z-50 flex w-64 flex-col gap-1 rounded-md border border-slate-300/[0.25] bg-white text-slate-700 shadow-lg transition-opacity duration-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{children}
</div>
);
}

View File

@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"files": [
"../../node_modules/@nx/react/typings/cssmodule.d.ts",
"../../node_modules/@nx/react/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"jest.config.ts"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -1,2 +1,3 @@
export * from './lib/project-details/project-details'; export * from './lib/project-details/project-details';
export * from './lib/utils/group-targets'; export * from './lib/utils/group-targets';
export * from './lib/pill';

View File

@ -6,7 +6,7 @@ export function Pill({
tooltip, tooltip,
}: { }: {
text: string; text: string;
color?: 'grey' | 'green' | 'yellow'; color?: 'grey' | 'green' | 'yellow' | 'red';
tooltip?: string; tooltip?: string;
}) { }) {
return ( return (
@ -19,7 +19,9 @@ export function Pill({
color === 'green' && color === 'green' &&
'bg-green-400/10 text-green-500 ring-green-500/40 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20', 'bg-green-400/10 text-green-500 ring-green-500/40 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20',
color === 'yellow' && color === 'yellow' &&
'bg-yellow-50 text-yellow-600 ring-yellow-500/40 dark:bg-yellow-900/30 dark:text-yellow-400 dark:ring-yellow-500/20' 'bg-yellow-50 text-yellow-600 ring-yellow-500/40 dark:bg-yellow-900/30 dark:text-yellow-400 dark:ring-yellow-500/20',
color === 'red' &&
'bg-red-50 text-red-600 ring-red-500/40 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/20'
)} )}
> >
{text} {text}

18
nx.json
View File

@ -240,7 +240,16 @@
"serveStaticTargetName": "serve-static" "serveStaticTargetName": "serve-static"
} }
}, },
"@nx/powerpack-enterprise-cloud" "@nx/powerpack-enterprise-cloud",
{
"plugin": "@nx/storybook/plugin",
"options": {
"serveStorybookTargetName": "serve:storybook",
"buildStorybookTargetName": "build:storybook",
"testStorybookTargetName": "test-storybook",
"staticStorybookTargetName": "static:storybook"
}
}
], ],
"nxCloudId": "62d013ea0852fe0a2df74438", "nxCloudId": "62d013ea0852fe0a2df74438",
"nxCloudUrl": "https://staging.nx.app", "nxCloudUrl": "https://staging.nx.app",
@ -371,5 +380,12 @@
} }
} }
] ]
},
"generators": {
"@nx/react": {
"library": {
"unitTestRunner": "jest"
}
}
} }
} }

View File

@ -114,14 +114,15 @@
"@rspack/dev-server": "1.0.9", "@rspack/dev-server": "1.0.9",
"@rspack/plugin-minify": "^0.7.5", "@rspack/plugin-minify": "^0.7.5",
"@rspack/plugin-react-refresh": "^1.0.0", "@rspack/plugin-react-refresh": "^1.0.0",
"@schematics/angular": "19.2.0", "@schematics/angular": "~19.2.0",
"@storybook/addon-essentials": "8.6.12", "@storybook/addon-essentials": "8.4.6",
"@storybook/addon-interactions": "8.6.12", "@storybook/addon-interactions": "8.4.6",
"@storybook/core-server": "8.6.12", "@storybook/core-server": "8.4.6",
"@storybook/react": "8.6.12", "@storybook/react": "8.4.6",
"@storybook/react-vite": "8.6.12", "@storybook/react-vite": "8.4.6",
"@storybook/react-webpack5": "8.6.12", "@storybook/react-webpack5": "8.4.6",
"@storybook/types": "8.6.12", "@storybook/test": "^8.5.1",
"@storybook/types": "^8.4.6",
"@supabase/supabase-js": "^2.26.0", "@supabase/supabase-js": "^2.26.0",
"@svgr/rollup": "^8.1.0", "@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^8.0.1", "@svgr/webpack": "^8.0.1",
@ -164,6 +165,7 @@
"@typescript-eslint/type-utils": "^8.19.0", "@typescript-eslint/type-utils": "^8.19.0",
"@typescript-eslint/utils": "^8.19.0", "@typescript-eslint/utils": "^8.19.0",
"@webcontainer/api": "1.5.1", "@webcontainer/api": "1.5.1",
"@vitejs/plugin-react": "^4.2.0",
"@xstate/immer": "0.3.1", "@xstate/immer": "0.3.1",
"@xstate/inspect": "0.7.0", "@xstate/inspect": "0.7.0",
"@xstate/react": "3.0.1", "@xstate/react": "3.0.1",

View File

@ -0,0 +1,321 @@
import { execSync } from 'child_process';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
import type { MigrationDetailsWithId } from '../../config/misc-interfaces';
import type { FileChange } from '../../generators/tree';
import {
getImplementationPath as getMigrationImplementationPath,
nxCliPath,
readMigrationCollection,
} from './migrate';
export type MigrationsJsonMetadata = {
completedMigrations?: Record<
string,
SuccessfulMigration | FailedMigration | SkippedMigration
>;
runningMigrations?: string[];
initialGitRef?: {
ref: string;
subject: string;
};
confirmedPackageUpdates?: boolean;
targetVersion?: string;
};
export type SuccessfulMigration = {
type: 'successful';
name: string;
changedFiles: Omit<FileChange, 'content'>[];
ref: string;
};
export type FailedMigration = {
type: 'failed';
name: string;
error: string;
};
export type SkippedMigration = {
type: 'skipped';
};
export function recordInitialMigrationMetadata(
workspacePath: string,
versionToMigrateTo: string
) {
const migrationsJsonPath = join(workspacePath, 'migrations.json');
const parsedMigrationsJson = JSON.parse(
readFileSync(migrationsJsonPath, 'utf-8')
);
const gitRef = execSync('git rev-parse HEAD', {
cwd: workspacePath,
encoding: 'utf-8',
}).trim();
const gitSubject = execSync('git log -1 --pretty=%s', {
cwd: workspacePath,
encoding: 'utf-8',
}).trim();
parsedMigrationsJson['nx-console'] = {
initialGitRef: {
ref: gitRef,
subject: gitSubject,
},
targetVersion: versionToMigrateTo,
};
writeFileSync(
migrationsJsonPath,
JSON.stringify(parsedMigrationsJson, null, 2)
);
}
export function finishMigrationProcess(
workspacePath: string,
squashCommits: boolean,
commitMessage: string
) {
const migrationsJsonPath = join(workspacePath, 'migrations.json');
const parsedMigrationsJson = JSON.parse(
readFileSync(migrationsJsonPath, 'utf-8')
);
const initialGitRef = (
parsedMigrationsJson['nx-console'] as MigrationsJsonMetadata
).initialGitRef;
if (existsSync(migrationsJsonPath)) {
rmSync(migrationsJsonPath);
}
execSync('git add .', {
cwd: workspacePath,
encoding: 'utf-8',
});
execSync(`git commit -m "${commitMessage}" --no-verify`, {
cwd: workspacePath,
encoding: 'utf-8',
});
if (squashCommits && initialGitRef) {
execSync(`git reset --soft ${initialGitRef.ref}`, {
cwd: workspacePath,
encoding: 'utf-8',
});
execSync(`git commit -m "${commitMessage}" --no-verify`, {
cwd: workspacePath,
encoding: 'utf-8',
});
}
}
export async function runSingleMigration(
workspacePath: string,
migration: MigrationDetailsWithId,
configuration: {
createCommits: boolean;
commitPrefix?: string;
}
) {
try {
modifyMigrationsJsonMetadata(
workspacePath,
addRunningMigration(migration.id)
);
const gitRefBefore = execSync('git rev-parse HEAD', {
cwd: workspacePath,
encoding: 'utf-8',
}).trim();
const cliPath = nxCliPath(workspacePath);
const updatedMigrateLocation = resolve(
cliPath,
'..',
'..',
'nx',
'src',
'command-line',
'migrate',
'migrate.js'
);
const updatedMigrateModule: typeof import('./migrate') = await import(
updatedMigrateLocation
);
const fileChanges = await updatedMigrateModule.runNxOrAngularMigration(
workspacePath,
migration,
false,
configuration.createCommits,
configuration.commitPrefix || 'chore: [nx migration] ',
undefined,
true
);
const gitRefAfter = execSync('git rev-parse HEAD', {
cwd: workspacePath,
encoding: 'utf-8',
}).trim();
modifyMigrationsJsonMetadata(
workspacePath,
addSuccessfulMigration(
migration.id,
fileChanges.map((change) => ({
path: change.path,
type: change.type,
})),
gitRefAfter
)
);
if (gitRefBefore !== gitRefAfter) {
execSync('git add migrations.json', {
cwd: workspacePath,
encoding: 'utf-8',
});
execSync('git commit --amend --no-verify --no-edit', {
cwd: workspacePath,
encoding: 'utf-8',
});
}
} catch (e) {
modifyMigrationsJsonMetadata(
workspacePath,
addFailedMigration(migration.id, e.message)
);
} finally {
modifyMigrationsJsonMetadata(
workspacePath,
removeRunningMigration(migration.id)
);
execSync('git add migrations.json', {
cwd: workspacePath,
encoding: 'utf-8',
});
}
}
export async function getImplementationPath(
workspacePath: string,
migration: MigrationDetailsWithId
) {
const { collection, collectionPath } = readMigrationCollection(
migration.package,
workspacePath
);
const { path } = getMigrationImplementationPath(
collection,
collectionPath,
migration.name
);
return path;
}
export function modifyMigrationsJsonMetadata(
workspacePath: string,
modify: (
migrationsJsonMetadata: MigrationsJsonMetadata
) => MigrationsJsonMetadata
) {
const migrationsJsonPath = join(workspacePath, 'migrations.json');
const migrationsJson = JSON.parse(readFileSync(migrationsJsonPath, 'utf-8'));
migrationsJson['nx-console'] = modify(migrationsJson['nx-console']);
writeFileSync(migrationsJsonPath, JSON.stringify(migrationsJson, null, 2));
}
export function addSuccessfulMigration(
id: string,
fileChanges: Omit<FileChange, 'content'>[],
ref: string
) {
return (
migrationsJsonMetadata: MigrationsJsonMetadata
): MigrationsJsonMetadata => {
const copied = { ...migrationsJsonMetadata };
if (!copied.completedMigrations) {
copied.completedMigrations = {};
}
copied.completedMigrations = {
...copied.completedMigrations,
[id]: {
type: 'successful',
name: id,
changedFiles: fileChanges,
ref,
},
};
return copied;
};
}
export function addFailedMigration(id: string, error: string) {
return (migrationsJsonMetadata: MigrationsJsonMetadata) => {
const copied = { ...migrationsJsonMetadata };
if (!copied.completedMigrations) {
copied.completedMigrations = {};
}
copied.completedMigrations = {
...copied.completedMigrations,
[id]: {
type: 'failed',
name: id,
error,
},
};
return copied;
};
}
export function addSkippedMigration(id: string) {
return (migrationsJsonMetadata: MigrationsJsonMetadata) => {
const copied = { ...migrationsJsonMetadata };
if (!copied.completedMigrations) {
copied.completedMigrations = {};
}
copied.completedMigrations = {
...copied.completedMigrations,
[id]: {
type: 'skipped',
},
};
return copied;
};
}
function addRunningMigration(id: string) {
return (migrationsJsonMetadata: MigrationsJsonMetadata) => {
migrationsJsonMetadata.runningMigrations = [
...(migrationsJsonMetadata.runningMigrations ?? []),
id,
];
return migrationsJsonMetadata;
};
}
function removeRunningMigration(id: string) {
return (migrationsJsonMetadata: MigrationsJsonMetadata) => {
migrationsJsonMetadata.runningMigrations =
migrationsJsonMetadata.runningMigrations?.filter((n) => n !== id);
if (migrationsJsonMetadata.runningMigrations?.length === 0) {
delete migrationsJsonMetadata.runningMigrations;
}
return migrationsJsonMetadata;
};
}
export function readMigrationsJsonMetadata(
workspacePath: string
): MigrationsJsonMetadata {
const migrationsJsonPath = join(workspacePath, 'migrations.json');
const migrationsJson = JSON.parse(readFileSync(migrationsJsonPath, 'utf-8'));
return migrationsJson['nx-console'];
}

View File

@ -23,7 +23,12 @@ import {
PackageJsonUpdates, PackageJsonUpdates,
} from '../../config/misc-interfaces'; } from '../../config/misc-interfaces';
import { NxJsonConfiguration } from '../../config/nx-json'; import { NxJsonConfiguration } from '../../config/nx-json';
import { flushChanges, FsTree, printChanges } from '../../generators/tree'; import {
FileChange,
flushChanges,
FsTree,
printChanges,
} from '../../generators/tree';
import { import {
extractFileFromTarball, extractFileFromTarball,
fileExists, fileExists,
@ -46,6 +51,8 @@ import {
createTempNpmDirectory, createTempNpmDirectory,
detectPackageManager, detectPackageManager,
getPackageManagerCommand, getPackageManagerCommand,
PackageManager,
PackageManagerCommands,
packageRegistryPack, packageRegistryPack,
packageRegistryView, packageRegistryView,
resolvePackageVersionUsingRegistry, resolvePackageVersionUsingRegistry,
@ -1311,9 +1318,12 @@ async function generateMigrationsJsonAndUpdatePackageJson(
// If for some reason it fails, it shouldn't affect the overall migration process // If for some reason it fails, it shouldn't affect the overall migration process
} }
output.log({ const bodyLines = process.env['NX_CONSOLE']
title: 'Next steps:', ? [
bodyLines: [ '- Inspect the package.json changes in the built-in diff editor [Click to open]',
'- Confirm the changes to install the new dependencies and continue the migration',
]
: [
`- Make sure package.json changes make sense and then run '${pmc.install}',`, `- Make sure package.json changes make sense and then run '${pmc.install}',`,
...(migrations.length > 0 ...(migrations.length > 0
? [`- Run '${pmc.exec} nx migrate --run-migrations'`] ? [`- Run '${pmc.exec} nx migrate --run-migrations'`]
@ -1335,7 +1345,11 @@ async function generateMigrationsJsonAndUpdatePackageJson(
)}' to get faster builds, GitHub integration, and more. Check out https://nx.app`, )}' to get faster builds, GitHub integration, and more. Check out https://nx.app`,
] ]
: []), : []),
], ];
output.log({
title: 'Next steps:',
bodyLines,
}); });
} catch (e) { } catch (e) {
output.error({ output.error({
@ -1402,17 +1416,28 @@ function showConnectToCloudMessage() {
} }
} }
function runInstall() { function runInstall(nxWorkspaceRoot?: string) {
const pmCommands = getPackageManagerCommand(); let packageManager: PackageManager;
let pmCommands: PackageManagerCommands;
if (nxWorkspaceRoot) {
packageManager = detectPackageManager(nxWorkspaceRoot);
pmCommands = getPackageManagerCommand(packageManager, nxWorkspaceRoot);
} else {
pmCommands = getPackageManagerCommand();
}
// TODO: remove this // TODO: remove this
if (detectPackageManager() === 'npm') { if (packageManager ?? detectPackageManager() === 'npm') {
process.env.npm_config_legacy_peer_deps ??= 'true'; process.env.npm_config_legacy_peer_deps ??= 'true';
} }
output.log({ output.log({
title: `Running '${pmCommands.install}' to make sure necessary packages are installed`, title: `Running '${pmCommands.install}' to make sure necessary packages are installed`,
}); });
execSync(pmCommands.install, { stdio: [0, 1, 2], windowsHide: false }); execSync(pmCommands.install, {
stdio: [0, 1, 2],
windowsHide: false,
cwd: nxWorkspaceRoot ?? process.cwd(),
});
} }
export async function executeMigrations( export async function executeMigrations(
@ -1428,14 +1453,7 @@ export async function executeMigrations(
shouldCreateCommits: boolean, shouldCreateCommits: boolean,
commitPrefix: string commitPrefix: string
) { ) {
let initialDeps = getStringifiedPackageJsonDeps(root); const changedDepInstaller = new ChangedDepInstaller(root);
const installDepsIfChanged = () => {
const currentDeps = getStringifiedPackageJsonDeps(root);
if (initialDeps !== currentDeps) {
runInstall();
}
initialDeps = currentDeps;
};
const migrationsWithNoChanges: typeof migrations = []; const migrationsWithNoChanges: typeof migrations = [];
const sortedMigrations = migrations.sort((a, b) => { const sortedMigrations = migrations.sort((a, b) => {
@ -1461,75 +1479,16 @@ export async function executeMigrations(
for (const m of sortedMigrations) { for (const m of sortedMigrations) {
logger.info(`Running migration ${m.package}: ${m.name}`); logger.info(`Running migration ${m.package}: ${m.name}`);
try { try {
const { collection, collectionPath } = readMigrationCollection( const changes = await runNxOrAngularMigration(
m.package,
root
);
if (!isAngularMigration(collection, collectionPath, m.name)) {
const changes = await runNxMigration(
root, root,
collectionPath, m,
collection, isVerbose,
m.name shouldCreateCommits,
commitPrefix,
() => changedDepInstaller.installDepsIfChanged()
); );
if (changes.length === 0) {
logger.info(`Ran ${m.name} from ${m.package}`);
logger.info(` ${m.description}\n`);
if (changes.length < 1) {
logger.info(`No changes were made\n`);
migrationsWithNoChanges.push(m); migrationsWithNoChanges.push(m);
continue;
}
logger.info('Changes:');
printChanges(changes, ' ');
logger.info('');
} else {
const ngCliAdapter = await getNgCompatLayer();
const { madeChanges, loggingQueue } = await ngCliAdapter.runMigration(
root,
m.package,
m.name,
readProjectsConfigurationFromProjectGraph(
await createProjectGraphAsync()
).projects,
isVerbose
);
logger.info(`Ran ${m.name} from ${m.package}`);
logger.info(` ${m.description}\n`);
if (!madeChanges) {
logger.info(`No changes were made\n`);
migrationsWithNoChanges.push(m);
continue;
}
logger.info('Changes:');
loggingQueue.forEach((log) => logger.info(' ' + log));
logger.info('');
}
if (shouldCreateCommits) {
installDepsIfChanged();
const commitMessage = `${commitPrefix}${m.name}`;
try {
const committedSha = commitChanges(commitMessage);
if (committedSha) {
logger.info(
chalk.dim(`- Commit created for changes: ${committedSha}`)
);
} else {
logger.info(
chalk.red(
`- A commit could not be created/retrieved for an unknown reason`
)
);
}
} catch (e) {
logger.info(chalk.red(`- ${e.message}`));
}
} }
logger.info(`---------------------------------------------------------`); logger.info(`---------------------------------------------------------`);
} catch (e) { } catch (e) {
@ -1541,12 +1500,119 @@ export async function executeMigrations(
} }
if (!shouldCreateCommits) { if (!shouldCreateCommits) {
installDepsIfChanged(); changedDepInstaller.installDepsIfChanged();
} }
return migrationsWithNoChanges; return migrationsWithNoChanges;
} }
class ChangedDepInstaller {
private initialDeps: string;
constructor(private readonly root: string) {
this.initialDeps = getStringifiedPackageJsonDeps(root);
}
public installDepsIfChanged() {
const currentDeps = getStringifiedPackageJsonDeps(this.root);
if (this.initialDeps !== currentDeps) {
runInstall(this.root);
}
this.initialDeps = currentDeps;
}
}
export async function runNxOrAngularMigration(
root: string,
migration: {
package: string;
name: string;
description?: string;
version: string;
cli?: 'nx' | 'angular';
},
isVerbose: boolean,
shouldCreateCommits: boolean,
commitPrefix: string,
installDepsIfChanged?: () => void,
handleInstallDeps = false
): Promise<FileChange[]> {
if (!installDepsIfChanged) {
const changedDepInstaller = new ChangedDepInstaller(root);
installDepsIfChanged = () => changedDepInstaller.installDepsIfChanged();
}
const { collection, collectionPath } = readMigrationCollection(
migration.package,
root
);
let changes: FileChange[] = [];
if (!isAngularMigration(collection, collectionPath, migration.name)) {
changes = await runNxMigration(
root,
collectionPath,
collection,
migration.name
);
logger.info(`Ran ${migration.name} from ${migration.package}`);
logger.info(` ${migration.description}\n`);
if (changes.length < 1) {
logger.info(`No changes were made\n`);
return [];
}
logger.info('Changes:');
printChanges(changes, ' ');
logger.info('');
} else {
const ngCliAdapter = await getNgCompatLayer();
const { madeChanges, loggingQueue } = await ngCliAdapter.runMigration(
root,
migration.package,
migration.name,
readProjectsConfigurationFromProjectGraph(await createProjectGraphAsync())
.projects,
isVerbose
);
logger.info(`Ran ${migration.name} from ${migration.package}`);
logger.info(` ${migration.description}\n`);
if (!madeChanges) {
logger.info(`No changes were made\n`);
return [];
}
logger.info('Changes:');
loggingQueue.forEach((log) => logger.info(' ' + log));
logger.info('');
}
if (shouldCreateCommits) {
installDepsIfChanged();
const commitMessage = `${commitPrefix}${migration.name}`;
try {
const committedSha = commitChanges(commitMessage, root);
if (committedSha) {
logger.info(chalk.dim(`- Commit created for changes: ${committedSha}`));
} else {
logger.info(
chalk.red(
`- A commit could not be created/retrieved for an unknown reason`
)
);
}
} catch (e) {
logger.info(chalk.red(`- ${e.message}`));
}
// if we are running this function alone, we need to install deps internally
} else if (handleInstallDeps) {
installDepsIfChanged();
}
return changes;
}
async function runMigrations( async function runMigrations(
root: string, root: string,
opts: { runMigrations: string; ifExists: boolean }, opts: { runMigrations: string; ifExists: boolean },
@ -1711,7 +1777,7 @@ export function runMigration() {
} }
} }
function readMigrationCollection(packageName: string, root: string) { export function readMigrationCollection(packageName: string, root: string) {
const collectionPath = readPackageMigrationConfig( const collectionPath = readPackageMigrationConfig(
packageName, packageName,
root root
@ -1724,7 +1790,7 @@ function readMigrationCollection(packageName: string, root: string) {
}; };
} }
function getImplementationPath( export function getImplementationPath(
collection: MigrationsJson, collection: MigrationsJson,
collectionPath: string, collectionPath: string,
name: string name: string
@ -1755,7 +1821,7 @@ function getImplementationPath(
return { path: implPath, fnSymbol }; return { path: implPath, fnSymbol };
} }
function nxCliPath() { export function nxCliPath(nxWorkspaceRoot?: string) {
const version = process.env.NX_MIGRATE_CLI_VERSION || 'latest'; const version = process.env.NX_MIGRATE_CLI_VERSION || 'latest';
try { try {
const packageManager = detectPackageManager(); const packageManager = detectPackageManager();
@ -1769,7 +1835,10 @@ function nxCliPath() {
}, },
license: 'MIT', license: 'MIT',
}); });
copyPackageManagerConfigurationFiles(workspaceRoot, tmpDir); copyPackageManagerConfigurationFiles(
nxWorkspaceRoot ?? workspaceRoot,
tmpDir
);
if (pmc.preInstall) { if (pmc.preInstall) {
// ensure package.json and repo in tmp folder is set to a proper package manager state // ensure package.json and repo in tmp folder is set to a proper package manager state
execSync(pmc.preInstall, { execSync(pmc.preInstall, {
@ -1795,7 +1864,7 @@ function nxCliPath() {
// Set NODE_PATH so that these modules can be used for module resolution // Set NODE_PATH so that these modules can be used for module resolution
addToNodePath(join(tmpDir, 'node_modules')); addToNodePath(join(tmpDir, 'node_modules'));
addToNodePath(join(workspaceRoot, 'node_modules')); addToNodePath(join(nxWorkspaceRoot ?? workspaceRoot, 'node_modules'));
return join(tmpDir, `node_modules`, '.bin', 'nx'); return join(tmpDir, `node_modules`, '.bin', 'nx');
} catch (e) { } catch (e) {

View File

@ -75,6 +75,17 @@ export interface MigrationsJsonEntry {
requires?: Record<string, string>; requires?: Record<string, string>;
} }
export type MigrationDetailsWithId = GeneratedMigrationDetails & {
id: string;
};
export interface GeneratedMigrationDetails {
name: string;
version: string;
package: string;
description: string;
implementation: string;
}
export interface MigrationsJson { export interface MigrationsJson {
name?: string; name?: string;
version?: string; version?: string;

View File

@ -298,7 +298,11 @@ export function commitChanges(
directory?: string directory?: string
): string | null { ): string | null {
try { try {
execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' }); execSync('git add -A', {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
});
execSync('git commit --no-verify -F -', { execSync('git commit --no-verify -F -', {
encoding: 'utf8', encoding: 'utf8',
stdio: 'pipe', stdio: 'pipe',
@ -318,15 +322,16 @@ export function commitChanges(
} }
} }
return getLatestCommitSha(); return getLatestCommitSha(directory);
} }
export function getLatestCommitSha(): string | null { export function getLatestCommitSha(directory?: string): string | null {
try { try {
return execSync('git rev-parse HEAD', { return execSync('git rev-parse HEAD', {
encoding: 'utf8', encoding: 'utf8',
stdio: 'pipe', stdio: 'pipe',
windowsHide: false, windowsHide: false,
cwd: directory,
}).trim(); }).trim();
} catch { } catch {
return null; return null;

2078
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,8 @@
"graph/ui-project-details/src/index.ts" "graph/ui-project-details/src/index.ts"
], ],
"@nx/graph-internal/ui-theme": ["graph/ui-theme/src/index.ts"], "@nx/graph-internal/ui-theme": ["graph/ui-theme/src/index.ts"],
"@nx/graph-migrate": ["graph/migrate/src/index.ts"],
"@nx/graph/ui-common": ["graph/ui-common/src/index.ts"],
"@nx/jest": ["packages/jest"], "@nx/jest": ["packages/jest"],
"@nx/jest/*": ["packages/jest/*"], "@nx/jest/*": ["packages/jest/*"],
"@nx/js": ["packages/js/src/index.ts"], "@nx/js": ["packages/js/src/index.ts"],