chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
@@ -0,0 +1,35 @@
import { defineConfig } from 'cypress';
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
const cypressJsonConfig = {
fileServerFolder: '.',
fixturesFolder: './src/fixtures',
video: true,
videosFolder: '../../dist/cypress/graph/client-e2e/videos',
screenshotsFolder: '../../dist/cypress/graph/client-e2e/screenshots',
chromeWebSecurity: false,
specPattern: './src/e2e/**/release-static*.cy.{js,jsx,ts,tsx}',
supportFile: 'src/support/e2e.ts',
};
export default defineConfig({
e2e: {
...nxE2EPreset(__dirname, {
webServerCommands: {
default:
'pnpm exec nx run graph-client:serve --configuration=release-static',
},
}),
baseUrl: 'http://localhost:4205',
...cypressJsonConfig,
/**
* TODO(@nx/cypress): In Cypress v12,the testIsolation option is turned on by default.
* This can cause tests to start breaking where not indended.
* You should consider enabling this once you verify tests do not depend on each other
* More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation
**/
testIsolation: false,
// Please ensure you use `cy.origin()` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
@@ -0,0 +1,34 @@
import { defineConfig } from 'cypress';
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
const cypressJsonConfig = {
fileServerFolder: '.',
fixturesFolder: './src/fixtures',
video: true,
videosFolder: '../../dist/cypress/graph/client-e2e/videos',
screenshotsFolder: '../../dist/cypress/graph/client-e2e/screenshots',
chromeWebSecurity: false,
specPattern: './src/e2e/**/release-serve-*.cy.{js,jsx,ts,tsx}',
supportFile: 'src/support/e2e.ts',
};
export default defineConfig({
e2e: {
...nxE2EPreset(__dirname, {
webServerCommands: {
default: 'pnpm exec nx run graph-client:serve --configuration=release',
},
}),
baseUrl: 'http://localhost:4203',
...cypressJsonConfig,
/**
* TODO(@nx/cypress): In Cypress v12,the testIsolation option is turned on by default.
* This can cause tests to start breaking where not indended.
* You should consider enabling this once you verify tests do not depend on each other
* More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation
**/
testIsolation: false,
// Please ensure you use `cy.origin()` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
@@ -0,0 +1,34 @@
import { defineConfig } from 'cypress';
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
const cypressJsonConfig = {
fileServerFolder: '.',
fixturesFolder: './src/fixtures',
video: true,
videosFolder: '../../dist/cypress/graph/client-e2e/videos',
screenshotsFolder: '../../dist/cypress/graph/client-e2e/screenshots',
chromeWebSecurity: false,
specPattern: './src/e2e/**/watch-mode-*.cy.{js,jsx,ts,tsx}',
supportFile: 'src/support/e2e.ts',
};
export default defineConfig({
e2e: {
...nxE2EPreset(__dirname, {
webServerCommands: {
default: 'pnpm exec nx run graph-client:serve --configuration=watch',
},
}),
baseUrl: 'http://localhost:4204',
...cypressJsonConfig,
/**
* TODO(@nx/cypress): In Cypress v12,the testIsolation option is turned on by default.
* This can cause tests to start breaking where not indended.
* You should consider enabling this once you verify tests do not depend on each other
* More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation
**/
testIsolation: false,
// Please ensure you use `cy.origin()` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'cypress';
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
const cypressJsonConfig = {
fileServerFolder: '.',
fixturesFolder: './src/fixtures',
video: true,
videosFolder: '../../dist/cypress/graph/client-e2e/videos',
screenshotsFolder: '../../dist/cypress/graph/client-e2e/screenshots',
chromeWebSecurity: false,
specPattern: 'src/e2e/**/dev-*.cy.{js,jsx,ts,tsx}',
supportFile: 'src/support/e2e.ts',
};
export default defineConfig({
e2e: {
...nxE2EPreset(__dirname, {
webServerCommands: {
default: 'pnpm exec nx run graph-client:serve --configuration=dev-e2e',
},
}),
baseUrl: 'http://localhost:4206',
...cypressJsonConfig,
/**
* TODO(@nx/cypress): In Cypress v12,the testIsolation option is turned on by default.
* This can cause tests to start breaking where not indended.
* You should consider enabling this once you verify tests do not depend on each other
* More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation
**/
testIsolation: false,
// Please ensure you use `cy.origin()` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
@@ -0,0 +1 @@
Cr24
+14
View File
@@ -0,0 +1,14 @@
import cypress from 'eslint-plugin-cypress';
import { baseConfig } from '../../eslint.config.mjs';
export default [
...baseConfig,
cypress.configs.recommended,
{
files: ['src/plugins/index.js'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
'no-undef': 'off',
},
},
];
+57
View File
@@ -0,0 +1,57 @@
{
"name": "e2e-graph-client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "graph/client-e2e/src",
"projectType": "application",
"targets": {
"e2e-base": {
"executor": "@nx/cypress:cypress",
"options": {
"testingType": "e2e",
"skipServe": true,
"browser": "chrome"
},
"configurations": {
"dev": {
"cypressConfig": "graph/client-e2e/cypress.config.ts"
},
"watch": {
"cypressConfig": "graph/client-e2e/cypress-watch-mode.config.ts"
},
"release": {
"cypressConfig": "graph/client-e2e/cypress-release.config.ts"
},
"release-static": {
"cypressConfig": "graph/client-e2e/cypress-release-static.config.ts"
}
},
"defaultConfiguration": "dev"
},
"e2e-local": {
"executor": "nx:run-commands",
"outputs": [],
"options": {
"commands": [
"npx nx e2e-base e2e-graph-client --configuration dev",
"npx nx e2e-base e2e-graph-client --configuration watch",
"npx nx e2e-base e2e-graph-client --configuration release",
"npx nx e2e-base e2e-graph-client --configuration release-static"
],
"parallel": false
}
},
"DISABLED__e2e": {
"executor": "nx:run-commands",
"outputs": [],
"options": {
"commands": [
"npx nx e2e-base e2e-graph-client --configuration dev",
"npx nx e2e-base e2e-graph-client --configuration release",
"npx nx e2e-base e2e-graph-client --configuration release-static"
],
"parallel": false
}
}
},
"implicitDependencies": ["graph-client"]
}
+102
View File
@@ -0,0 +1,102 @@
describe('dev mode - app', () => {
before(() => {
cy.intercept('/assets/project-graphs/e2e.json', {
fixture: 'nx-examples-project-graph.json',
}).as('getGraph');
cy.visit('/e2e/projects');
// wait for initial graph to finish loading
cy.wait('@getGraph');
});
describe('theme preferences', () => {
let systemTheme: string;
before(() => {
cy.visit('/e2e/projects');
systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
it('should initialize localstorage with default theme', () => {
expect(localStorage.getItem('nx-dep-graph-theme')).eq('system');
});
it('has system default theme', () => {
cy.log('system theme is:', systemTheme);
cy.get('html').should('have.class', systemTheme);
});
describe('dark theme is set as prefered', () => {
before(() => {
cy.get('[data-cy="theme-open-modal-button"]').click();
cy.get('[data-cy="dark-theme-button"]').click();
});
it('should set dark theme', () => {
cy.log('Localstorage is: ', localStorage.getItem('nx-dep-graph-theme'));
expect(localStorage.getItem('nx-dep-graph-theme')).eq('dark');
cy.get('html').should('have.class', 'dark');
});
});
describe('light theme is set as preferred', () => {
before(() => {
cy.get('[data-cy="theme-open-modal-button"]').click();
cy.get('[data-cy="light-theme-button"]').click();
});
it('should set light theme', () => {
cy.log('Localstorage is: ', localStorage.getItem('nx-dep-graph-theme'));
expect(localStorage.getItem('nx-dep-graph-theme')).eq('light');
cy.get('html').should('have.class', 'light');
});
});
});
describe('graph layout direction preferences', () => {
let rankDir: string;
before(() => {
cy.visit('/e2e/projects');
rankDir = 'TB';
});
it('should initialize localstorage with default graph layout direction', () => {
expect(localStorage.getItem('nx-dep-graph-rankdir')).eq(rankDir);
});
describe('left-to-right graph layout direction is set as preferred', () => {
before(() => {
cy.get('[data-cy="lr-rankdir-button"]').click();
});
it('should set left-to-right graph layout direction', () => {
cy.log(
'Localstorage is: ',
localStorage.getItem('nx-dep-graph-rankdir')
);
expect(localStorage.getItem('nx-dep-graph-rankdir')).eq('LR');
cy.get('[data-cy="tb-rankdir-button"]').should(
(elem) => expect(elem).to.exist
);
});
});
describe('top-to-bottom graph layout direction is set as preferred', () => {
before(() => {
cy.get('[data-cy="tb-rankdir-button"]').click();
});
it('should set top-to-bottom graph layout direction', () => {
cy.log(
'Localstorage is: ',
localStorage.getItem('nx-dep-graph-rankdir')
);
expect(localStorage.getItem('nx-dep-graph-rankdir')).eq('TB');
cy.get('[data-cy="lr-rankdir-button"]').should(
(elem) => expect(elem).to.exist
);
});
});
});
});
@@ -0,0 +1,398 @@
import {
getCheckedProjectItems,
getDeselectAllButton,
getFocusButtonForProject,
getGroupByFolderCheckbox,
getImageDownloadButton,
getIncludeProjectsInPathButton,
getSearchDepthCheckbox,
getSearchDepthDecrementButton,
getSearchDepthIncrementButton,
getSelectAffectedButton,
getSelectAllButton,
getSelectProjectsMessage,
getTextFilterInput,
getTextFilterReset,
getToggleAllButtonForFolder,
getUncheckedProjectItems,
getUnfocusProjectButton,
} from '../support/app.po';
import * as affectedJson from '../fixtures/affected.json';
import { testProjectsRoutes } from '../support/routing-tests';
import * as nxExamplesJson from '../fixtures/nx-examples-project-graph.json';
describe('dev mode - project graph', () => {
before(() => {
cy.intercept('/assets/project-graphs/e2e.json', {
fixture: 'nx-examples-project-graph.json',
}).as('getGraph');
cy.visit('/e2e/projects');
// wait for initial graph to finish loading
cy.wait('@getGraph');
});
afterEach(() => {
// clean up by hiding all projects and clearing text input
getDeselectAllButton().click();
getTextFilterInput().clear().type('{enter}');
getCheckedProjectItems().should('have.length', 0);
});
describe('select projects message', () => {
it('should display on load', () => {
getSelectProjectsMessage().should('be.visible');
});
it('should hide when a project is selected', () => {
cy.contains('cart').as('cart');
cy.get('@cart').scrollIntoView();
cy.get('@cart').should('be.visible');
cy.get('[data-project="cart"]').should('be.visible');
cy.get('[data-project="cart"]').click({ force: true });
getSelectProjectsMessage().should('not.exist');
});
});
describe('text filter', () => {
it('should hide clear button initially', () => {
getTextFilterReset().should('not.exist');
});
it('should show clear button after typing', () => {
getTextFilterInput().type('cart');
getTextFilterReset().should('exist');
});
it('should clear selection on reset', () => {
getTextFilterInput().type('cart');
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.filter((project) =>
project.name.includes('cart')
).length
);
getTextFilterReset().click();
getCheckedProjectItems().should('have.length', 0);
});
it('should hide clear button after clicking', () => {
getTextFilterInput().type('cart');
getTextFilterReset().click().should('not.exist');
});
it('should filter projects by text when pressing enter', () => {
getTextFilterInput().type('cart{enter}');
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.filter((project) =>
project.name.includes('cart')
).length
);
});
it('should filter projects by text after debounce', () => {
getTextFilterInput().type('cart');
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.filter((project) =>
project.name.includes('cart')
).length
);
});
it('should include projects in path when option is checked', () => {
getTextFilterInput().type('cart');
getIncludeProjectsInPathButton().click();
getCheckedProjectItems().should(
'have.length.gt',
nxExamplesJson.projects.filter((project) =>
project.name.includes('cart')
).length
);
});
});
describe('proximity', () => {
it('should change when increment/decrement button is clicked', () => {
cy.get('[data-cy="depth-value"]').should('contain', '1');
getSearchDepthIncrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '2');
getSearchDepthDecrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '1');
});
it("should reactivate proximity if it's disabled and a button is clicked", () => {
getSearchDepthIncrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '2');
getSearchDepthCheckbox()
.should('be.checked')
.click()
.should('not.be.checked')
.click();
getSearchDepthIncrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '3');
getSearchDepthCheckbox()
.should('be.checked')
.click()
.should('not.be.checked')
.click();
getSearchDepthDecrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '2');
getSearchDepthCheckbox()
.should('be.checked')
.click()
.should('not.be.checked')
.click();
// return to 1 for following tests
getSearchDepthDecrementButton().click();
});
it('should not go below 1', () => {
getSearchDepthIncrementButton().click();
getSearchDepthIncrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '3');
getSearchDepthDecrementButton().click();
getSearchDepthDecrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '1');
getSearchDepthDecrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '1');
});
});
describe('select all button', () => {
it('should check all project items', () => {
getSelectAllButton().scrollIntoView().click({ force: true });
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.length
);
});
});
describe('deselect all button', () => {
it('should uncheck all project items', () => {
getDeselectAllButton().click();
getUncheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.length
);
getSelectProjectsMessage().should('be.visible');
});
});
describe('show affected button', () => {
it('should be hidden initially', () => {
getSelectAffectedButton().should('not.exist');
});
it('should check all affected project items', () => {
cy.intercept('/assets/project-graphs/affected.json', {
fixture: 'affected.json',
}).as('getAffectedGraph');
cy.get('[data-cy=project-select]').select('affected', { force: true });
cy.wait('@getAffectedGraph');
getSelectAffectedButton().click();
getCheckedProjectItems().should(
'have.length',
affectedJson.affected.length
);
// switch back to Nx Examples graph before proceeding
cy.intercept('/assets/project-graphs/e2e.json', {
fixture: 'nx-examples-project-graph.json',
}).as('getGraph');
cy.get('[data-cy=project-select]').select('e2e', { force: true });
cy.wait('@getGraph');
});
});
describe('selecting projects', () => {
it('should select a project by clicking on the project name', () => {
cy.get('[data-project="cart"]').should('have.data', 'active', false);
cy.get('[data-project="cart"]').click({
force: true,
});
cy.get('[data-project="cart"][data-active="true"]').should('exist');
});
it('should deselect a project by clicking on the project name again', () => {
cy.get('[data-project="cart"]').click({
force: true,
});
cy.get('[data-project="cart"][data-active="true"]')
.should('exist')
.click({
force: true,
});
cy.get('[data-project="cart"][data-active="false"]').should('exist');
});
it('should select a project by clicking on the selected icon', () => {
cy.get('[data-project="cart"][data-active="false"]').click({
force: true,
});
cy.get('[data-project="cart"][data-active="true"]')
.should('exist')
.parent()
.siblings()
.first()
.should('exist')
.click({
force: true,
});
cy.get('[data-project="cart"][data-active="false"]').should('exist');
});
});
describe('focusing projects in sidebar', () => {
it('should select appropriate projects', () => {
cy.contains('cart').as('cart');
cy.get('@cart').scrollIntoView();
cy.get('@cart').should('be.visible');
getFocusButtonForProject('cart').click({ force: true });
const dependencies = nxExamplesJson.dependencies.cart;
const dependents = Object.keys(nxExamplesJson.dependencies).filter(
(key) =>
nxExamplesJson.dependencies[key]
.map((dependencies) => dependencies.target)
.includes('cart')
);
getUnfocusProjectButton().should('exist');
getCheckedProjectItems().should(
'have.length',
['cart', ...dependencies, ...dependents].length
);
});
it('should url encode projects with special chars', () => {
getFocusButtonForProject('@scoped/project-a').click({ force: true });
cy.url().should('include', '%40scoped%2Fproject-a');
});
});
describe('unfocus button', () => {
it('should uncheck all project items', () => {
getFocusButtonForProject('cart').click({ force: true });
getUnfocusProjectButton().click();
getUnfocusProjectButton().should('not.exist');
getCheckedProjectItems().should('have.length', 0);
});
});
describe('toggle all projects in folder button', () => {
it('should check all projects in folder if at least one project checked', () => {
cy.contains('shared-product-state').as('shared-product-state');
cy.get('@shared-product-state').scrollIntoView();
cy.get('@shared-product-state').should('be.visible');
cy.get('[data-project="shared-product-state"]').should('be.visible');
cy.get('[data-project="shared-product-state"]').click({ force: true });
getToggleAllButtonForFolder('shared/product').click({ force: true });
getCheckedProjectItems().should('have.length', 4);
});
it('should check all projects in folder if no projects checked yet', () => {
getToggleAllButtonForFolder('shared').click({ force: true });
getCheckedProjectItems().should('have.length', 5);
});
it('should uncheck all projects in folder if all projects checked yet', () => {
getToggleAllButtonForFolder('shared').click({ force: true });
getCheckedProjectItems().should('have.length', 5);
getToggleAllButtonForFolder('shared').click({ force: true });
getCheckedProjectItems().should('have.length', 0);
});
});
describe('image download button', () => {
it('should be hidden initally', () => {
getImageDownloadButton().should('have.class', 'opacity-0');
});
it('should be shown when a project is selected', () => {
cy.get('[data-project="cart"]').click({ force: true });
getImageDownloadButton().should('not.have.class', 'opacity-0');
});
it('should be hidden when no more projects are selected', () => {
cy.get('[data-project="cart"]').click({ force: true });
getDeselectAllButton().click();
getImageDownloadButton().should('have.class', 'opacity-0');
});
});
describe('setting url params', () => {
it('should set focused project', () => {
cy.contains('cart').as('cart');
cy.get('@cart').scrollIntoView();
cy.get('@cart').should('be.visible');
getFocusButtonForProject('cart').click({ force: true });
getUnfocusProjectButton().should('exist');
cy.url().should('contain', '/projects/cart');
cy.reload();
getUnfocusProjectButton().should('exist');
});
it('should set group by folder', () => {
getGroupByFolderCheckbox().click();
getGroupByFolderCheckbox().should('be.checked');
cy.url().should('contain', 'groupByFolder=true');
cy.reload();
getGroupByFolderCheckbox().should('be.checked');
});
it('should set search depth disabled', () => {
// it's on by default, clicking should disable it
getSearchDepthCheckbox().click();
getSearchDepthCheckbox().should('not.be.checked');
cy.url().should('contain', 'searchDepth=0');
cy.reload();
getSearchDepthCheckbox().should('not.be.checked');
// re-enable to clean-up for following tests
getSearchDepthCheckbox().click();
});
it('should set search depth if greater than 1', () => {
// it's on by default and set to 1, clicking should change it to 2
getSearchDepthIncrementButton().click();
cy.get('[data-cy="depth-value"]').should('contain', '2');
cy.url().should('contain', 'searchDepth=2');
cy.reload();
cy.get('[data-cy="depth-value"]').should('contain', '2');
});
it('should set select to all', () => {
getSelectAllButton().click();
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.length
);
cy.url().should('contain', '/projects/all');
cy.reload();
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.length
);
});
});
});
describe('loading graph client with url params', () => {
beforeEach(() => {
cy.intercept('/assets/project-graphs/*', {
fixture: 'nx-examples-project-graph.json',
}).as('getGraph');
});
testProjectsRoutes('browser', ['/e2e/projects']);
});
@@ -0,0 +1,237 @@
import {
getCheckedProjectItems,
getDeselectAllButton,
getFocusButtonForProject,
getGroupByFolderCheckbox,
getGroupByProjectCheckbox,
getImageDownloadButton,
getIncludeProjectsInPathButton,
getSearchDepthCheckbox,
getSearchDepthIncrementButton,
getSelectAffectedButton,
getSelectAllButton,
getSelectProjectsMessage,
getSelectTargetDropdown,
getSelectTasksMessage,
getTextFilterInput,
getTextFilterReset,
getToggleAllButtonForFolder,
getUncheckedProjectItems,
getUnfocusProjectButton,
openTooltipForNode,
} from '../support/app.po';
import * as affectedJson from '../fixtures/affected.json';
import { testProjectsRoutes, testTaskRoutes } from '../support/routing-tests';
import * as nxExamplesTaskInputs from '../fixtures/nx-examples-task-inputs.json';
describe('dev mode - task graph', () => {
before(() => {
cy.intercept('/assets/project-graphs/e2e.json', {
fixture: 'nx-examples-project-graph.json',
}).as('getGraph');
cy.intercept('/assets/task-graphs/e2e.json', {
fixture: 'nx-examples-task-graphs.json',
}).as('getTaskGraph');
cy.visit('/e2e/tasks');
// wait for initial graphs to finish loading
cy.wait('@getGraph');
cy.wait('@getTaskGraph');
});
afterEach(() => {
// clean up by hiding all tasks
getDeselectAllButton().click();
getCheckedProjectItems().should('have.length', 0);
});
describe('select tasks message', () => {
it('should display on load', () => {
getSelectTasksMessage().should('be.visible');
});
it('should hide when a project is selected', () => {
cy.contains('cart').as('cart');
cy.get('@cart').scrollIntoView();
cy.get('@cart').should('be.visible');
cy.get('[data-project="cart"]').should('be.visible');
cy.get('[data-project="cart"]').click({ force: true });
getSelectTasksMessage().should('not.exist');
});
});
describe('select all button', () => {
it('should check all project items', () => {
getSelectAllButton().scrollIntoView().click({ force: true });
getCheckedProjectItems().should('have.length', 2);
});
it('should remove all from the url when no longer selected', () => {
getSelectAllButton().scrollIntoView().click({ force: true });
cy.url().should('contain', '/all');
getDeselectAllButton().click();
cy.url().should('not.contain', '/all');
getSelectAllButton().scrollIntoView().click({ force: true });
cy.url().should('contain', '/all');
cy.get('[data-project="cart"]').click({
force: true,
});
cy.url().should('not.contain', '/all');
});
});
describe('deselect all button', () => {
it('should uncheck all project items', () => {
getDeselectAllButton().click();
getUncheckedProjectItems().should('have.length', 2);
getSelectTasksMessage().should('be.visible');
});
});
describe('selecting projects', () => {
it('should select a project by clicking on the project name', () => {
cy.get('[data-project="cart"]').should('have.data', 'active', false);
cy.get('[data-project="cart"]').click({
force: true,
});
cy.get('[data-project="cart"][data-active="true"]').should('exist');
});
it('should deselect a project by clicking on the project name again', () => {
cy.get('[data-project="cart"]').click({
force: true,
});
cy.get('[data-project="cart"][data-active="true"]')
.should('exist')
.click({
force: true,
});
cy.get('[data-project="cart"][data-active="false"]').should('exist');
});
it('should select a project by clicking on the selected icon', () => {
cy.get('[data-project="cart"][data-active="false"]').click({
force: true,
});
cy.get('[data-project="cart"][data-active="true"]')
.should('exist')
.parent()
.siblings()
.first()
.should('exist')
.click({
force: true,
});
cy.get('[data-project="cart"][data-active="false"]').should('exist');
});
});
describe('image download button', () => {
it('should be hidden initally', () => {
getImageDownloadButton().should('have.class', 'opacity-0');
});
it('should be shown when a project is selected', () => {
cy.get('[data-project="cart"]').click({ force: true });
getImageDownloadButton().should('not.have.class', 'opacity-0');
});
it('should be hidden when no more projects are selected', () => {
cy.get('[data-project="cart"]').click({ force: true });
getDeselectAllButton().click();
getImageDownloadButton().should('have.class', 'opacity-0');
});
});
describe('setting route params', () => {
it('should set selected target', () => {
getSelectTargetDropdown().select('e2e', { force: true });
cy.url().should('contain', 'tasks/e2e');
});
it('should set group by project', () => {
getGroupByProjectCheckbox().click();
cy.url().should('contain', 'groupByProject=true');
});
});
describe('loading graph client with url params', () => {
beforeEach(() => {
cy.intercept('/assets/project-graphs/*', {
fixture: 'nx-examples-project-graph.json',
}).as('getGraph');
cy.intercept('/assets/task-graphs/e2e.json', {
fixture: 'nx-examples-task-graphs.json',
}).as('getTaskGraphs');
});
// check that params work from old base url of /
// and also new /projects route
testTaskRoutes('browser', ['/e2e/tasks']);
});
describe('file inputs', () => {
beforeEach(() => {
cy.intercept(
{
method: 'GET',
url: '/task-inputs.json*',
},
async (req) => {
// Extract the desired query parameter
const taskId = req.url.split('taskId=')[1];
// Load the fixture data and find the property based on the query parameter
const expandedInputs = nxExamplesTaskInputs[taskId];
// Reply with the selected property
req.reply({
body: expandedInputs,
});
}
).as('getTaskInputs');
});
it('should display input files', () => {
getSelectTargetDropdown().select('build', { force: true });
cy.get('[data-project="cart"]').click({
force: true,
});
openTooltipForNode('cart:build');
cy.get('[data-cy="inputs-accordion"]').click();
cy.get('[data-cy="input-list-entry"]').should('have.length', 18);
const expectedSections = [
'cart-cart-page',
'shared-assets',
'shared-header',
'shared-styles',
'External Inputs',
];
cy.get('[data-cy="input-section-entry"]').each((el, idx) => {
expect(el.text()).to.equal(expectedSections[idx]);
});
const sharedHeaderSelector =
'[data-cy="input-section-entry"]:contains(shared-header)';
cy.get(sharedHeaderSelector).click();
cy.get(sharedHeaderSelector)
.nextAll('[data-cy="input-list-entry"]')
.should('have.length', 9);
});
});
});
@@ -0,0 +1,18 @@
describe('release serve-mode app', () => {
beforeEach(() => {
cy.intercept('/assets/project-graphs/*').as('getGraph');
cy.visit('/');
// wait for first graph to finish loading
cy.wait('@getGraph');
});
it('should not display experimental features', () => {
cy.get('experimental-features').should('not.exist');
});
it('should not display the debugger', () => {
cy.get('debugger-panel').should('not.exist');
});
});
@@ -0,0 +1,97 @@
import { testProjectsRoutes, testTaskRoutes } from '../support/routing-tests';
import {
getCheckedProjectItems,
getDeselectAllButton,
getFocusButtonForProject,
getTextFilterInput,
getUnfocusProjectButton,
} from '../support/app.po';
import * as nxExamplesJson from '../fixtures/nx-examples-project-graph.json';
describe('release static-mode app', () => {
describe('smoke tests', () => {
beforeEach(() => {
cy.visit('/');
});
it('should not display experimental features', () => {
cy.get('experimental-features').should('not.exist');
});
it('should not display the debugger', () => {
cy.get('debugger-panel').should('not.exist');
});
it('should use hash router', () => {
cy.url().should('contain', '/#/projects');
});
});
describe('routing', () => {
testProjectsRoutes('hash', ['/projects']);
});
describe('api tests', () => {
before(() => {
cy.visit('/#/projects/');
});
afterEach(() => {
// clean up by hiding all projects and clearing text input
getDeselectAllButton().click();
getTextFilterInput().clear();
getCheckedProjectItems().should('have.length', 0);
});
describe('externalApi public api', () => {
it('should focus project', () => {
cy.window().then((window) => {
// Cast the cypress AUTWindow to the augmented global Window (tsgo
// doesn't surface the Window augmentation through AUTWindow).
(window as Window).externalApi.focusProject('cart');
checkFocusedProject(nxExamplesJson, 'cart');
});
});
it('should select all projects', () => {
cy.window().then((window) => {
(window as Window).externalApi.selectAllProjects();
checkSelectAll(nxExamplesJson);
});
});
});
});
});
function checkFocusedProject(projectGraphJson: any, projectName: string) {
const dependencies = projectGraphJson.dependencies.cart;
const dependents = Object.keys(nxExamplesJson.dependencies).filter((key) =>
nxExamplesJson.dependencies[key]
.map((dependencies) => dependencies.target)
.includes(projectName)
);
getUnfocusProjectButton().should('exist');
getCheckedProjectItems().should(
'have.length',
[projectName, ...dependencies, ...dependents].length
);
cy.url().should('contain', `/projects/${projectName}`);
}
function checkSelectAll(projectGraphJson: any) {
getCheckedProjectItems().should(
'have.length',
projectGraphJson.projects.length
);
cy.url().should('contain', `/projects/all`);
}
function checkSelectedProject(projectName: string) {
cy.get(`[data-project="${projectName}"][data-active="true"]`).should('exist');
}
function checkDeselectedProject(projectName: string) {
cy.get(`[data-project="${projectName}"][data-active="true"]`).should(
'not.exist'
);
}
@@ -0,0 +1,53 @@
import { getProjectItems } from '../support/app.po';
describe('watch mode - app', () => {
beforeEach(() => {
cy.visit('/projects');
});
it('should auto-select new libs as they are created', () => {
const excludedValues = ['existing-app-1', 'existing-lib-1'];
checkSelectedProjects(2, excludedValues);
checkSelectedProjects(3, excludedValues);
checkSelectedProjects(4, excludedValues);
});
it('should retain selected projects as new libs are created', () => {
cy.get('[data-project="existing-app-1"]').click();
cy.get('[data-project="existing-lib-1"]').click();
checkSelectedProjects(3, []);
checkSelectedProjects(4, []);
checkSelectedProjects(5, []);
});
it('should not re-add new libs if they were un-selected', () => {
cy.get('[data-project*="3"][data-active="true"]', { timeout: 6000 })
.should('exist')
.click({ force: true });
cy.get('[data-project*="3"][data-active="false"]', {
timeout: 6000,
}).should('exist');
cy.get('[data-project*="3"]', { timeout: 6000 })
.first()
.should((project) => {
expect(project.data('active')).to.be.false;
});
});
});
function checkSelectedProjects(
expectedNumberOfProjects: number,
excludedProjects: string[]
) {
getProjectItems().should((projects) => {
expect(projects.length).to.equal(expectedNumberOfProjects);
projects.each(function () {
if (!excludedProjects.includes(this.dataset.project)) {
expect(this.dataset.active).to.eq('true');
}
});
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,450 @@
{
"taskGraph": {
"tasks": {
"products-product-detail-page:lint": {
"id": "products-product-detail-page:lint",
"target": {
"project": "products-product-detail-page",
"target": "lint"
},
"projectRoot": "libs/products/product-detail-page",
"overrides": {}
},
"products-product-detail-page:test": {
"id": "products-product-detail-page:test",
"target": {
"project": "products-product-detail-page",
"target": "test"
},
"projectRoot": "libs/products/product-detail-page",
"overrides": {}
},
"shared-product-state:lint": {
"id": "shared-product-state:lint",
"target": {
"project": "shared-product-state",
"target": "lint"
},
"projectRoot": "libs/shared/product/state",
"overrides": {}
},
"shared-product-state:test": {
"id": "shared-product-state:test",
"target": {
"project": "shared-product-state",
"target": "test"
},
"projectRoot": "libs/shared/product/state",
"overrides": {}
},
"shared-product-types:lint": {
"id": "shared-product-types:lint",
"target": {
"project": "shared-product-types",
"target": "lint"
},
"projectRoot": "libs/shared/product/types",
"overrides": {}
},
"shared-product-data:lint": {
"id": "shared-product-data:lint",
"target": {
"project": "shared-product-data",
"target": "lint"
},
"projectRoot": "libs/shared/product/data",
"overrides": {}
},
"products-home-page:lint": {
"id": "products-home-page:lint",
"target": {
"project": "products-home-page",
"target": "lint"
},
"projectRoot": "libs/products/home-page",
"overrides": {}
},
"products-home-page:test": {
"id": "products-home-page:test",
"target": {
"project": "products-home-page",
"target": "test"
},
"projectRoot": "libs/products/home-page",
"overrides": {}
},
"shared-cart-state:lint": {
"id": "shared-cart-state:lint",
"target": {
"project": "shared-cart-state",
"target": "lint"
},
"projectRoot": "libs/shared/cart/state",
"overrides": {}
},
"shared-cart-state:test": {
"id": "shared-cart-state:test",
"target": {
"project": "shared-cart-state",
"target": "test"
},
"projectRoot": "libs/shared/cart/state",
"overrides": {}
},
"shared-product-ui:lint": {
"id": "shared-product-ui:lint",
"target": {
"project": "shared-product-ui",
"target": "lint"
},
"projectRoot": "libs/shared/product/ui",
"overrides": {}
},
"shared-product-ui:test": {
"id": "shared-product-ui:test",
"target": {
"project": "shared-product-ui",
"target": "test"
},
"projectRoot": "libs/shared/product/ui",
"overrides": {}
},
"shared-e2e-utils:lint": {
"id": "shared-e2e-utils:lint",
"target": {
"project": "shared-e2e-utils",
"target": "lint"
},
"projectRoot": "libs/shared/e2e-utils",
"overrides": {}
},
"cart-cart-page:lint": {
"id": "cart-cart-page:lint",
"target": {
"project": "cart-cart-page",
"target": "lint"
},
"projectRoot": "libs/cart/cart-page",
"overrides": {}
},
"cart-cart-page:test": {
"id": "cart-cart-page:test",
"target": {
"project": "cart-cart-page",
"target": "test"
},
"projectRoot": "libs/cart/cart-page",
"overrides": {}
},
"shared-header:lint": {
"id": "shared-header:lint",
"target": {
"project": "shared-header",
"target": "lint"
},
"projectRoot": "libs/shared/header",
"overrides": {}
},
"shared-header:test": {
"id": "shared-header:test",
"target": {
"project": "shared-header",
"target": "test"
},
"projectRoot": "libs/shared/header",
"overrides": {}
},
"shared-jsxify:lint": {
"id": "shared-jsxify:lint",
"target": {
"project": "shared-jsxify",
"target": "lint"
},
"projectRoot": "libs/shared/jsxify",
"overrides": {}
},
"products-e2e:e2e": {
"id": "products-e2e:e2e",
"target": {
"project": "products-e2e",
"target": "e2e"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"products-e2e:e2e:production": {
"id": "products-e2e:e2e:production",
"target": {
"project": "products-e2e",
"target": "e2e",
"configuration": "production"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"products-e2e:lint": {
"id": "products-e2e:lint",
"target": {
"project": "products-e2e",
"target": "lint"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"cart-e2e:e2e": {
"id": "cart-e2e:e2e",
"target": {
"project": "cart-e2e",
"target": "e2e"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"cart-e2e:e2e:production": {
"id": "cart-e2e:e2e:production",
"target": {
"project": "cart-e2e",
"target": "e2e",
"configuration": "production"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"cart-e2e:lint": {
"id": "cart-e2e:lint",
"target": {
"project": "cart-e2e",
"target": "lint"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"products:build": {
"id": "products:build",
"target": {
"project": "products",
"target": "build"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:build:production": {
"id": "products:build:production",
"target": {
"project": "products",
"target": "build",
"configuration": "production"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:serve": {
"id": "products:serve",
"target": {
"project": "products",
"target": "serve"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:serve:production": {
"id": "products:serve:production",
"target": {
"project": "products",
"target": "serve",
"configuration": "production"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:extract-i18n": {
"id": "products:extract-i18n",
"target": {
"project": "products",
"target": "extract-i18n"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:lint": {
"id": "products:lint",
"target": {
"project": "products",
"target": "lint"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:test": {
"id": "products:test",
"target": {
"project": "products",
"target": "test"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:deploy": {
"id": "products:deploy",
"target": {
"project": "products",
"target": "deploy"
},
"projectRoot": "apps/products",
"overrides": {}
},
"cart:build:production": {
"id": "cart:build:production",
"target": {
"project": "cart",
"target": "build",
"configuration": "production"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:build:development": {
"id": "cart:build:development",
"target": {
"project": "cart",
"target": "build",
"configuration": "development"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:serve:development": {
"id": "cart:serve:development",
"target": {
"project": "cart",
"target": "serve",
"configuration": "development"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:serve:production": {
"id": "cart:serve:production",
"target": {
"project": "cart",
"target": "serve",
"configuration": "production"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:lint": {
"id": "cart:lint",
"target": {
"project": "cart",
"target": "lint"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:test": {
"id": "cart:test",
"target": {
"project": "cart",
"target": "test"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:deploy": {
"id": "cart:deploy",
"target": {
"project": "cart",
"target": "deploy"
},
"projectRoot": "apps/cart",
"overrides": {}
}
},
"dependencies": {
"products-product-detail-page:lint": [],
"products-product-detail-page:test": [],
"shared-product-state:lint": [],
"shared-product-state:test": [],
"shared-product-types:lint": [],
"shared-product-data:lint": [],
"products-home-page:lint": [],
"products-home-page:test": [],
"shared-cart-state:lint": [],
"shared-cart-state:test": [],
"shared-product-ui:lint": [],
"shared-product-ui:test": [],
"shared-e2e-utils:lint": [],
"cart-cart-page:lint": [],
"cart-cart-page:test": [],
"shared-header:lint": [],
"shared-header:test": [],
"shared-jsxify:lint": [],
"products-e2e:e2e": [],
"products-e2e:e2e:production": [],
"products-e2e:lint": [],
"cart-e2e:e2e": [],
"cart-e2e:e2e:production": [],
"cart-e2e:lint": [],
"products:build": [],
"products:build:production": [],
"products:serve": [],
"products:serve:production": [],
"products:extract-i18n": [],
"products:lint": [],
"products:test": [],
"products:deploy": [],
"cart:build:production": [],
"cart:build:development": [],
"cart:serve:development": [],
"cart:serve:production": [],
"cart:lint": [],
"cart:test": [],
"cart:deploy": []
},
"continuousDependencies": {},
"roots": [
"products-product-detail-page:lint",
"products-product-detail-page:test",
"shared-product-state:lint",
"shared-product-state:test",
"shared-product-types:lint",
"shared-product-data:lint",
"products-home-page:lint",
"products-home-page:test",
"shared-cart-state:lint",
"shared-cart-state:test",
"shared-product-ui:lint",
"shared-product-ui:test",
"shared-e2e-utils:lint",
"cart-cart-page:lint",
"cart-cart-page:test",
"shared-header:lint",
"shared-header:test",
"shared-jsxify:lint",
"products-e2e:e2e",
"products-e2e:e2e:production",
"products-e2e:lint",
"cart-e2e:e2e",
"cart-e2e:e2e:production",
"cart-e2e:lint",
"products:build",
"products:build:production",
"products:serve",
"products:serve:production",
"products:extract-i18n",
"products:lint",
"products:test",
"products:deploy",
"cart:build:production",
"cart:build:development",
"cart:serve:development",
"cart:serve:production",
"cart:lint",
"cart:test",
"cart:deploy"
]
},
"plans": {},
"error": null
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
// ensure we have the types for the externalApi on window
// eslint-disable-next-line @nx/enforce-module-boundaries
import type { ExternalApi } from '@nx/graph/shared';
declare global {
interface Window {
externalApi: ExternalApi;
}
}
+18
View File
@@ -0,0 +1,18 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
// Preprocess Typescript file using Nx helper
};
+56
View File
@@ -0,0 +1,56 @@
export const getSelectProjectsMessage = () =>
cy.contains('Please select a project in the sidebar');
export const getSelectTasksMessage = () =>
cy.contains('Please select a task in the sidebar');
export const getGraph = () => cy.get('#graph-container');
export const getSelectAllButton = () => cy.get('[data-cy=selectAllButton]');
export const getDeselectAllButton = () => cy.get('[data-cy=deselectAllButton]');
export const getSelectAffectedButton = () => cy.get('[data-cy=affectedButton]');
export const getUnfocusProjectButton = () => cy.get('[data-cy=unfocusButton]');
export const getProjectItems = () =>
cy.get('[data-project]', { timeout: 6000 });
export const getCheckedProjectItems = () => cy.get('[data-active="true"]');
export const getUncheckedProjectItems = () => cy.get('[data-active="false"]');
export const getGroupByFolderCheckbox = () =>
cy.get('input[name=groupByFolder]');
export const getGroupByProjectCheckbox = () =>
cy.get('input[name=groupByProject]');
export const getSearchDepthCheckbox = () =>
cy.get('input[name=depthFilter][value=depthFilterActivated]');
export const getTextFilterInput = () => cy.get('[data-cy=textFilterInput]');
export const getTextFilterReset = () => cy.get('[data-cy=textFilterReset]');
export const getSearchDepthDecrementButton = () =>
cy.get('[data-cy=decrement-depth-filter]');
export const getSearchDepthIncrementButton = () =>
cy.get('[data-cy=increment-depth-filter]');
export const getIncludeProjectsInPathButton = () =>
cy.get('input[name=textFilterCheckbox]');
export const getImageDownloadButton = () =>
cy.get('[data-cy=downloadImageButton]');
export const getFocusButtonForProject = (projectName: string) =>
cy.get(`[data-cy="focus-button-${projectName}"]`);
export const getToggleAllButtonForFolder = (folderName: string) =>
cy.get(`[data-cy="toggle-folder-visibility-button-${folderName}"]`);
export const getSelectTargetDropdown = () =>
cy.get('[data-cy=selected-target-dropdown]');
export const openTooltipForNode = (nodeId: string) =>
cy.window().then((window) => {
// @ts-ignore - we will access private methods only in this e2e test
const pos = window.externalApi._graphService.renderGraph.cy
.$(nodeId)
.renderedPosition();
cy.get('#cytoscape-graph').click(pos.x, pos.y);
});
+35
View File
@@ -0,0 +1,35 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
declare global {
namespace Cypress {
interface Chainable<Subject> {
login(email: string, password: string): void;
}
}
}
//
// -- This is a parent command --
Cypress.Commands.add('login', (email: string, password: string) => {
console.log('Custom command example: Login', email, password);
});
export {};
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+17
View File
@@ -0,0 +1,17 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
@@ -0,0 +1,177 @@
import * as nxExamplesJson from '../fixtures/nx-examples-project-graph.json';
import {
getCheckedProjectItems,
getGroupByFolderCheckbox,
getGroupByProjectCheckbox,
getSearchDepthCheckbox,
getSelectTargetDropdown,
} from './app.po';
function waitForProjectGraph(router: 'hash' | 'browser') {
if (router === 'browser') {
cy.wait('@getGraph');
}
}
function waitForTaskGraphs(router: 'hash' | 'browser') {
if (router === 'browser') {
cy.wait('@getTaskGraphs');
}
}
function resolveProjectsRoute(
router: 'hash' | 'browser',
route: string,
paramString: string
) {
if (router === 'hash') {
return `/#${route}?${paramString}`;
} else {
return `${route}?${paramString}`;
}
}
function resolveTasksRoute(
router: 'hash' | 'browser',
route: string,
paramString: string
) {
if (router === 'hash') {
return `/#${route}?${paramString}`;
} else {
return `${route}?${paramString}`;
}
}
export function testProjectsRoutes(
router: 'hash' | 'browser',
routes: string[]
) {
routes.forEach((route) => {
describe(`for route ${route}`, () => {
it('should focus projects', () => {
cy.visit(resolveProjectsRoute(router, `${route}/cart`, ''));
// wait for first graph to finish loading
waitForProjectGraph(router);
const dependencies = nxExamplesJson.dependencies.cart;
const dependents = Object.keys(nxExamplesJson.dependencies).filter(
(key) =>
nxExamplesJson.dependencies[key]
.map((dependencies) => dependencies.target)
.includes('cart')
);
getCheckedProjectItems().should(
'have.length',
['cart', ...dependencies, ...dependents].length
);
});
it('should focus projects with special characters', () => {
cy.visit(
resolveProjectsRoute(router, `${route}/%40scoped%2Fproject-a`, '')
);
// wait for first graph to finish loading
waitForProjectGraph(router);
const dependencies = nxExamplesJson.dependencies['@scoped/project-a'];
const dependents = Object.keys(nxExamplesJson.dependencies).filter(
(key) =>
nxExamplesJson.dependencies[key]
.map((dependencies) => dependencies.target)
.includes('@scoped/project-a')
);
getCheckedProjectItems().should(
'have.length',
['@scoped/project-a', ...dependencies, ...dependents].length
);
});
it('should focus projects with search depth', () => {
cy.visit(
resolveProjectsRoute(router, `${route}/cart`, `searchDepth=2`)
);
// wait for first graph to finish loading
waitForProjectGraph(router);
getCheckedProjectItems().should('have.length', 10);
getSearchDepthCheckbox().should('exist');
});
it('should focus projects with search depth disabled', () => {
cy.visit(
resolveProjectsRoute(router, `${route}/cart`, `searchDepth=0`)
);
// wait for first graph to finish loading
waitForProjectGraph(router);
getCheckedProjectItems().should('have.length', 12);
getSearchDepthCheckbox().should('exist');
});
it('should set group by folder', () => {
cy.visit(
resolveProjectsRoute(router, `${route}/cart`, `groupByFolder=true`)
);
// wait for first graph to finish loading
waitForProjectGraph(router);
getGroupByFolderCheckbox().should('be.checked');
});
it('should select all projects', () => {
cy.visit(resolveProjectsRoute(router, `${route}/all`, ``));
// wait for first graph to finish loading
waitForProjectGraph(router);
getCheckedProjectItems().should(
'have.length',
nxExamplesJson.projects.length
);
});
});
});
}
export function testTaskRoutes(router: 'hash' | 'browser', routes: string[]) {
routes.forEach((route) => {
describe(`for route ${route}`, () => {
it('should set group by project', () => {
cy.visit(resolveTasksRoute(router, route, `groupByProject=true`));
// wait for first graph to finish loading
waitForProjectGraph(router);
waitForTaskGraphs(router);
getGroupByProjectCheckbox().should('be.checked');
});
it('should set selected target', () => {
cy.visit(resolveTasksRoute(router, `${route}/e2e`, ''));
// wait for first graph to finish loading
waitForProjectGraph(router);
waitForTaskGraphs(router);
getSelectTargetDropdown().should('have.value', 'e2e');
});
it('should select all', () => {
cy.visit(resolveTasksRoute(router, `${route}/e2e/all`, ''));
// wait for first graph to finish loading
waitForProjectGraph(router);
waitForTaskGraphs(router);
getSelectTargetDropdown().should('have.value', 'e2e');
getCheckedProjectItems().should('have.length', 2);
});
});
});
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"sourceMap": false,
"outDir": "out-tsc",
"allowJs": true,
"types": ["cypress", "node"],
"typeRoots": [
"../../node_modules/@types",
"../../node_modules/.pnpm/cypress@14.3.0/node_modules/cypress/types"
],
"lib": ["DOM"]
},
"files": [],
"include": [
"cypress.config.ts",
"cypress-watch-mode.config.ts",
"cypress-release.config.ts",
"cypress-release-static.config.ts",
"src/**/*.ts",
"src/**/*.js",
"src/**/*.json"
],
"references": [],
"exclude": ["out-tsc"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic"
}
]
],
"plugins": []
}
+16
View File
@@ -0,0 +1,16 @@
# This file is used by:
# 1. autoprefixer to adjust CSS to support the below specified browsers
# 2. babel preset-env to adjust included polyfills
#
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
#
# If you need to support different browsers in production, you may tweak the list below.
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major version
last 2 iOS major versions
Firefox ESR
not IE 9-11 # For IE 9-11 support, remove 'not'.
+28
View File
@@ -0,0 +1,28 @@
import { baseConfig, reactHooksV7Off } from '../../eslint.config.mjs';
import nx from '@nx/eslint-plugin';
export default [
...baseConfig,
...nx.configs['flat/react'],
...reactHooksV7Off,
{
rules: {
'@typescript-eslint/no-implicit-any': 'off',
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'no-restricted-imports': [
'error',
{
paths: ['rxjs'],
patterns: ['rxjs/*'],
},
],
},
},
{
ignores: ['src/assets/generated-*'],
},
];
+19
View File
@@ -0,0 +1,19 @@
// nx-ignore-next-line
const nxPreset = require('@nx/jest/preset').default;
module.exports = {
...nxPreset,
displayName: 'graph-client',
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/client',
// 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: [
...(nxPreset.modulePathIgnorePatterns ?? []),
'/graph/client/src/app/machines/match-media-mock.spec.ts',
],
};
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@nx/graph-client",
"version": "0.0.1",
"type": "commonjs",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"devDependencies": {
"jest": "catalog:jest"
},
"dependencies": {
"@nx/graph-internal-ui-render-config": "workspace:",
"@nx/graph-internal-project-details": "workspace:",
"@nx/graph-internal-ui-project-details": "workspace:",
"@nx/graph-migrate": "workspace:",
"@nx/graph-shared": "workspace:",
"@nx/graph-ui-common": "workspace:",
"@nx/graph-ui-code-block": "workspace:"
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
plugins: [require('@tailwindcss/postcss'), require('autoprefixer')],
};
+99
View File
@@ -0,0 +1,99 @@
{
"name": "graph-client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "graph/client/src",
"projectType": "application",
"targets": {
"lint": {
"options": {
"max-warnings": 15
}
},
"generate-dev-environment-js": {
"executor": "nx:run-commands",
"options": {
"commands": ["tsx ./scripts/generate-graph-environment.ts"]
}
},
"generate-graph-base": {
"executor": "nx:run-commands",
"options": {
"commands": ["tsx ./scripts/generate-graph.ts"]
}
},
"generate-graph": {
"executor": "nx:run-commands",
"options": {
"commands": [
"npx nx run graph-client:generate-graph-base",
"npx nx run graph-client:generate-dev-environment-js"
],
"parallel": false
}
},
"build-client": {
"dependsOn": ["nx:build-native"],
"configurations": {
"dev": {},
"nx-console": {},
"release": {},
"watch": {},
"release-static": {},
"dev-e2e": {}
},
"defaultConfiguration": "release"
},
"typecheck": {
"dependsOn": ["^typecheck", "nx:build-native"],
"inputs": [
"...",
"{workspaceRoot}/packages/devkit/tsconfig.lib.json",
"{workspaceRoot}/packages/nx/tsconfig.lib.json"
]
},
"serve-base": {
"configurations": {
"dev": {},
"nx-console": {},
"release": {},
"watch": {},
"release-static": {},
"dev-e2e": {}
},
"defaultConfiguration": "dev"
},
"serve": {
"executor": "nx:run-commands",
"configurations": {
"dev": {
"commands": [
"npx nx run graph-client:generate-dev-environment-js",
"npx nx serve-base graph-client"
],
"parallel": false
},
"dev-e2e": {
"commands": ["npx nx serve-base graph-client --configuration dev-e2e"]
},
"release": {
"commands": ["npx nx serve-base graph-client --configuration release"]
},
"release-static": {
"commands": [
"npx nx serve-base graph-client --configuration release-static"
]
},
"watch": {
"commands": ["npx nx serve-base graph-client --configuration watch"]
},
"nx-console": {
"commands": [
"npx nx serve-base graph-client --configuration nx-console"
]
}
},
"defaultConfiguration": "dev"
}
},
"tags": []
}
+16
View File
@@ -0,0 +1,16 @@
import { RouterProvider } from 'react-router-dom';
import { getRouter } from './get-router';
import {
ThemeProvider,
RankDirProvider,
} from '@nx/graph-internal-ui-render-config';
export function App() {
return (
<ThemeProvider>
<RankDirProvider>
<RouterProvider router={getRouter()} />
</RankDirProvider>
</ThemeProvider>
);
}
@@ -0,0 +1,146 @@
// 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';
import { MigrateUI } from '@nx/graph-migrate';
import { getExternalApiService } from '@nx/graph-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 onUndoMigration = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'undo-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 },
});
};
const onStopMigration = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'stop-migration',
payload: { migration },
});
};
const onAcknowledgePrompt = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'acknowledge-prompt',
payload: { migration },
});
};
const onViewPrompt = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'view-prompt',
payload: { migration },
});
};
return (
<MigrateUI
migrations={migrations}
nxConsoleMetadata={nxConsoleMetadata}
onRunMigration={onRunMigration}
onRunMany={onRunMany}
onCancel={onCancel}
onFinish={onFinish}
onFileClick={onFileClick}
onSkipMigration={onSkipMigration}
onUndoMigration={onUndoMigration}
onAcknowledgePrompt={onAcknowledgePrompt}
onViewImplementation={onViewImplementation}
onViewDocumentation={onViewDocumentation}
onViewPrompt={onViewPrompt}
onStopMigration={onStopMigration}
></MigrateUI>
);
}
@@ -0,0 +1,62 @@
import { assign } from '@xstate/immer';
// nx-ignore-next-line
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';
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'];
}),
],
},
],
},
});
@@ -0,0 +1,107 @@
import { ProjectDetails } from '@nx/graph-internal-ui-project-details';
import { getExternalApiService } from '@nx/graph-shared';
import { ErrorToastUI } from '@nx/graph-ui-common';
import { ExpandedTargetsProvider } from '@nx/graph-internal-ui-project-details';
import { useSelector } from '@xstate/react';
import { useCallback } from 'react';
import { Interpreter } from 'xstate';
import {
ProjectDetailsEvents,
ProjectDetailsState,
} from './project-details.machine';
import { GraphStateSerializer } from '@nx/graph';
import { ProjectElement } from '@nx/graph/projects';
export function ProjectDetailsApp({
service,
}: {
service: Interpreter<ProjectDetailsState, any, ProjectDetailsEvents>;
}) {
const externalApiService = getExternalApiService();
const project = useSelector(service, (state) => state.context.project);
const sourceMap = useSelector(service, (state) => state.context.sourceMap);
const errors = useSelector(service, (state) => state.context.errors);
const connectedToCloud = useSelector(
service,
(state) => state.context.connectedToCloud
);
const disabledTaskSyncGenerators = useSelector(
service,
(state) => state.context.disabledTaskSyncGenerators
);
const handleViewInProjectGraph = useCallback(
(data: { projectName: string }) => {
const serializedState = GraphStateSerializer.serialize({
c: {},
s: {
type: 'focused',
nodeId: ProjectElement.makeId('project', data.projectName),
},
});
externalApiService.postEvent({
type: 'open-project-graph',
payload: {
projectName: data.projectName,
serializedProjectGraphState: serializedState,
},
});
},
[externalApiService]
);
const handleViewInTaskGraph = useCallback(
(data: { projectName: string; targetName: string }) => {
externalApiService.postEvent({
type: 'open-task-graph',
payload: {
projectName: data.projectName,
targetName: data.targetName,
},
});
},
[externalApiService]
);
const handleRunTarget = useCallback(
(data: { projectName: string; targetName: string }) => {
externalApiService.postEvent({
type: 'run-task',
payload: { taskId: `${data.projectName}:${data.targetName}` },
});
},
[externalApiService]
);
const handleNxConnect = useCallback(
() =>
externalApiService.postEvent({
type: 'nx-connect',
}),
[externalApiService]
);
if (project && sourceMap) {
return (
<>
<ExpandedTargetsProvider>
<ProjectDetails
project={project as any}
sourceMap={sourceMap}
onViewInProjectGraph={handleViewInProjectGraph}
onViewInTaskGraph={handleViewInTaskGraph}
onRunTarget={handleRunTarget}
viewInProjectGraphPosition="bottom"
connectedToCloud={connectedToCloud}
onNxConnect={handleNxConnect}
disabledTaskSyncGenerators={disabledTaskSyncGenerators}
/>
</ExpandedTargetsProvider>
<ErrorToastUI errors={errors} />
</>
);
} else {
return null;
}
}
@@ -0,0 +1,49 @@
import { interpret } from 'xstate';
import { projectDetailsMachine } from './project-details.machine';
describe('graphMachine', () => {
let service;
beforeEach(() => {
service = interpret(projectDetailsMachine).start();
});
afterEach(() => {
service.stop();
});
it('should have initial idle state', () => {
expect(service.state.value).toEqual('idle');
expect(service.state.context.project).toEqual(null);
expect(service.state.context.errors).toBeUndefined();
});
it('should handle setting data', () => {
service.send({
type: 'loadData',
project: {
type: 'app',
name: 'proj',
data: {},
},
sourceMap: {
root: ['project.json', 'nx-core-build-project-json-nodes'],
},
errors: [{ name: 'ERROR' }],
connectedToCloud: true,
});
expect(service.state.value).toEqual('loaded');
expect(service.state.context.project).toEqual({
type: 'app',
name: 'proj',
data: {},
});
expect(service.state.context.sourceMap).toEqual({
root: ['project.json', 'nx-core-build-project-json-nodes'],
});
expect(service.state.context.errors).toEqual([{ name: 'ERROR' }]);
expect(service.state.context.connectedToCloud).toEqual(true);
});
});
@@ -0,0 +1,60 @@
// nx-ignore-next-line
import type { ProjectGraphProjectNode } from '@nx/devkit';
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
import { createMachine } from 'xstate';
import { assign } from '@xstate/immer';
export interface ProjectDetailsState {
project: null | ProjectGraphProjectNode;
sourceMap: null | Record<string, string[]>;
errors?: GraphError[];
connectedToCloud?: boolean;
disabledTaskSyncGenerators?: string[];
}
export type ProjectDetailsEvents = {
type: 'loadData';
project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>;
connectedToCloud: boolean;
errors?: GraphError[];
disabledTaskSyncGenerators?: string[];
};
const initialContext: ProjectDetailsState = {
project: null,
sourceMap: null,
};
export const projectDetailsMachine = createMachine<
ProjectDetailsState,
ProjectDetailsEvents
>({
predictableActionArguments: true,
preserveActionOrder: true,
id: 'project-view',
initial: 'idle',
context: initialContext,
states: {
idle: {},
loaded: {},
},
on: {
loadData: [
{
target: 'loaded',
actions: [
assign((ctx, event) => {
ctx.project = event.project;
ctx.sourceMap = event.sourceMap;
ctx.connectedToCloud = event.connectedToCloud;
ctx.errors = event.errors;
ctx.disabledTaskSyncGenerators = event.disabledTaskSyncGenerators;
}),
],
},
],
},
});
+117
View File
@@ -0,0 +1,117 @@
import { ExternalApi, getExternalApiService } from '@nx/graph-shared';
import { getRouter } from './get-router';
import { GraphStateSerializer } from '@nx/graph';
import { ProjectElement } from '@nx/graph/projects';
export class ExternalApiImpl extends ExternalApi {
router = getRouter();
externalApiService = getExternalApiService();
constructor() {
super();
this.externalApiService.subscribe(
({ type, payload }: { type: string; payload: any }) => {
if (!this.graphInteractionEventListener) {
console.log('graphInteractionEventListener not registered.');
return;
}
this.graphInteractionEventListener({
type,
payload,
});
}
);
// make sure properties set before are taken into account again
if (window.externalApi?.loadProjectGraph) {
this.loadProjectGraph = window.externalApi.loadProjectGraph;
}
if (window.externalApi?.loadTaskGraph) {
this.loadTaskGraph = window.externalApi.loadTaskGraph;
}
if (window.externalApi?.loadExpandedTaskInputs) {
this.loadExpandedTaskInputs = window.externalApi.loadExpandedTaskInputs;
}
if (window.externalApi?.loadSourceMaps) {
this.loadSourceMaps = window.externalApi.loadSourceMaps;
}
if (window.externalApi?.graphInteractionEventListener) {
this.graphInteractionEventListener =
window.externalApi.graphInteractionEventListener;
}
}
async openProjectDetails(projectName: string, targetName?: string) {
await this.router.navigate(
`/project-details/${encodeURIComponent(projectName)}`
);
if (targetName) {
this.focusTargetInProjectDetails(targetName);
}
}
focusTargetInProjectDetails(targetName: string) {
const currentLocation = this.router.state.location;
const searchParams = new URLSearchParams(currentLocation.search);
searchParams.set('expanded', targetName);
const newUrl = `${currentLocation.pathname}?${searchParams.toString()}`;
this.router.navigate(newUrl);
}
focusProject(projectName: string) {
const serializedState = GraphStateSerializer.serialize({
c: {},
s: {
type: 'focused',
nodeId: ProjectElement.makeId('project', projectName),
},
});
const searchParams = new URLSearchParams();
searchParams.set('graph', serializedState);
this.router.navigate(`/projects?${searchParams.toString()}`);
}
selectAllProjects() {
const serializedState = GraphStateSerializer.serialize({
c: { showMode: 'all' },
});
const searchParams = new URLSearchParams();
searchParams.set('graph', serializedState);
this.router.navigate(`/projects?${searchParams.toString()}`);
}
showAffectedProjects() {
const serializedState = GraphStateSerializer.serialize({
c: { showMode: 'affected' },
});
const searchParams = new URLSearchParams();
searchParams.set('graph', serializedState);
this.router.navigate(`/projects?${searchParams.toString()}`);
}
focusTarget(projectName: string, targetName: string) {
this.router.navigate(
`/tasks?targets=${encodeURIComponent(
targetName
)}&projects=${encodeURIComponent(projectName)}`
);
}
selectAllTargetsByName(targetName: string) {
this.router.navigate(
`/tasks/all?targets=${encodeURIComponent(targetName)}`
);
}
enableExperimentalFeatures() {
localStorage.setItem('showExperimentalFeatures', 'true');
window.appConfig.showExperimentalFeatures = true;
}
disableExperimentalFeatures() {
localStorage.setItem('showExperimentalFeatures', 'false');
window.appConfig.showExperimentalFeatures = false;
}
}
@@ -0,0 +1,572 @@
// nx-ignore-next-line
import {
GraphError,
ProjectGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
import {
fetchProjectGraph,
getExternalApiService,
getProjectGraphDataService,
useEnvironmentConfig,
usePoll,
useRouteConstructor,
} from '@nx/graph-shared';
import { useRankDir, useTheme } from '@nx/graph-internal-ui-render-config';
import {
Link,
useNavigate,
useParams,
useRouteLoaderData,
useSearchParams,
} from 'react-router-dom';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import {
NxGraphProjectGraphProvider,
ProjectGraphHandleEventResult,
useProjectGraphContext,
} from '@nx/graph/projects';
import {
NxGraphEmpty,
NxGraphDebuggerPanel,
useElementPanel,
NxGraphToolbar,
NxGraphToolbarItemGroup,
NxGraphThemeTool,
NxGraphRankDirTool,
NxGraphResetLayoutTool,
NxGraphResetGraphTool,
NxGraphShareTool,
NxGraphProjectModeTool,
NxGraphShowModeTool,
NxGraphGroupByFolderTool,
NxGraphCollapseEdgesTool,
NxGraphElementPanel,
NxGraphProjectNodePanelContent,
NxGraphProjectNodePanelHeader,
NxGraphPanel,
NxGraphPanelItemGroup,
NxGraphDependencyDistanceControl,
NxGraphProjectListControl,
NxGraphCompositeProjectNodePanelHeader,
NxGraphProjectEdgePanelHeader,
NxGraphCompositeProjectEdgePanelHeader,
NxGraphCompositeProjectNodePanelContent,
NxGraphCompositeProjectEdgePanelContent,
NxGraphProjectEdgePanelContent,
} from '@nx/graph/ui';
import { ElementData, useRendererEvents } from '@nx/graph';
import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline';
import { ErrorToast } from '@nx/graph-ui-common';
import classNames from 'classnames';
import { Tab, TabGroup, TabList } from '@headlessui/react';
import { useCurrentPath } from '../hooks/use-current-path';
export function ProjectsShell() {
const environmentConfig = useEnvironmentConfig();
const { errors: routerErrors } = useRouteLoaderData(
'selectedWorkspace'
) as ProjectGraphClientResponse;
const [errors, setErrors] = useState<GraphError[] | undefined>(routerErrors);
const renderPlatform = useMemo(
() =>
environmentConfig.environment === 'nx-console' ? 'nx-console' : 'nx',
[environmentConfig.environment]
);
useEffect(() => {
setErrors(routerErrors);
}, [routerErrors]);
return (
<>
<NxGraphProjectGraphProvider renderPlatform={renderPlatform}>
<ProjectsShellInner />
</NxGraphProjectGraphProvider>
<ErrorToast errors={errors} />
</>
);
}
function ProjectsShellInner() {
const projectGraphDataService = getProjectGraphDataService();
const externalApiService = getExternalApiService();
const graphContext = useProjectGraphContext();
const {
containerRef,
send,
orchestrator,
sendRendererConfigEvent,
restoreGraphState,
serializedGraphState,
handleEventResult,
} = graphContext;
const navigate = useNavigate();
const routeConstructor = useRouteConstructor();
const { currentPath } = useCurrentPath();
const { resolvedTheme, theme, setTheme } = useTheme();
const { rankDir, setRankDir } = useRankDir();
const environmentConfig = useEnvironmentConfig();
const params = useParams();
const selectedWorkspaceLoaderData = useRouteLoaderData(
'selectedWorkspace'
) as ProjectGraphClientResponse;
const [lastHash, setLastHash] = useState(selectedWorkspaceLoaderData.hash);
const [searchParams, setSearchParams] = useSearchParams();
const graphState = searchParams.get('graph');
useRendererEvents(orchestrator, {
compositeProjectNodeDoubleTap: ({ data }) => {
if (data.expanded) {
send({ type: 'collapseCompositeNode', compositeNodeId: data.id });
} else {
send({
type: 'selectNode',
nodeId: data.id,
pendingAction: { type: 'expand' },
});
}
},
});
const [element] = useElementPanel<
| ElementData.ProjectNode
| ElementData.CompositeProjectNode
| ElementData.SelectedCompositeProjectEdge
| ElementData.SelectedProjectEdge
>(orchestrator);
useEffect(() => {
if (!orchestrator) return;
send(
{
type: 'initGraph',
projects: selectedWorkspaceLoaderData.projects,
dependencies: selectedWorkspaceLoaderData.dependencies,
fileMap: selectedWorkspaceLoaderData.fileMap,
affectedProjects: selectedWorkspaceLoaderData.affected,
},
{
type: 'updateRendererConfig',
updater: () => ({
autoExpand: 15,
rankDir,
theme: resolvedTheme,
showMode: 'all',
}),
},
{ type: 'showAll' }
);
}, [orchestrator]);
useEffect(() => {
sendRendererConfigEvent({ type: 'themeChange', theme: resolvedTheme });
}, [resolvedTheme]);
useEffect(() => {
sendRendererConfigEvent({ type: 'rankDirChange', rankDir });
}, [rankDir]);
useEffect(() => {
if (!graphState || !orchestrator || graphState === serializedGraphState) {
return;
}
const result = restoreGraphState(graphState);
if (!result) return;
if (result.rendererConfig.rankDir !== rankDir) {
setRankDir(result.rendererConfig.rankDir);
}
if (
result.rendererConfig.platform !== 'nx-console' &&
result.rendererConfig.theme !== resolvedTheme
) {
setTheme(result.rendererConfig.theme);
}
}, [graphState, orchestrator]);
useEffect(() => {
setSearchParams(
(params) => {
const currentGraphParams = params.get('graph');
// this means serialization went wrong
if (serializedGraphState === null && currentGraphParams) {
params.delete('graph');
return params;
}
if (
serializedGraphState &&
currentGraphParams !== serializedGraphState
) {
params.set('graph', serializedGraphState);
}
return params;
},
{ preventScrollReset: true }
);
}, [serializedGraphState]);
usePoll(
async () => {
const response: ProjectGraphClientResponse = await fetchProjectGraph(
projectGraphDataService,
params,
environmentConfig.appConfig
);
if (response.hash === lastHash) {
return;
}
send({
type: 'updateGraph',
projects: response.projects,
dependencies: response.dependencies,
fileMap: response.fileMap,
affectedProjects: response.affected,
});
setLastHash(response.hash);
},
5000,
environmentConfig.watch
);
const onViewProjectDetailsClick = (project: ElementData.ProjectNode) => {
if (handleEventResult.rendererConfig.platform === 'nx-dev') return;
if (handleEventResult.rendererConfig.platform === 'nx-console') {
return externalApiService.postEvent({
type: 'open-project-config',
payload: { projectName: project.name, projectId: project.id },
});
}
return navigate(
routeConstructor(
{ pathname: `/project-details/${encodeURIComponent(project.name)}` },
() => {
const searchParams = new URLSearchParams();
searchParams.set('projectId', project.id);
return searchParams;
}
)
);
};
return (
<div className="flex h-full w-full flex-col">
{environmentConfig.appConfig.showDebugger ? (
<NxGraphDebuggerPanel
position="top"
containerClassName="static bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300 border-slate-300 dark:border-slate-800"
expandButtonClassName="text-slate-600 dark:text-slate-200 hover:text-slate-900 dark:hover:text-slate-100 bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600"
copyButtonClassName="text-slate-600 dark:text-slate-200 hover:text-slate-900 dark:hover:text-slate-100 bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600"
textareaClassName="bg-slate-50 dark:bg-slate-800"
statsClassName="text-slate-600 dark:text-slate-200"
textareaLabelClassName="text-slate-600 dark:text-slate-200"
/>
) : null}
<div className="relative h-full w-full bg-white dark:bg-slate-900">
<div
ref={containerRef}
className="h-full w-full bg-white dark:bg-slate-900"
id="cytoscape-graph"
></div>
<NxGraphToolbar toolbarClassName="border-slate-300 dark:border-slate-700 divide-slate-300 dark:divide-slate-700 z-50 divide-x">
<NxGraphToolbarItemGroup className="pr-0">
{handleEventResult.rendererConfig.platform !== 'nx-console' ? (
<NxGraphThemeTool
theme={theme}
setTheme={setTheme}
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
toolPopoverPanelItemActiveClassName="text-slate-200 bg-sky-500 dark:bg-sky-600"
/>
) : null}
<NxGraphRankDirTool
onRankDirChange={setRankDir}
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
toolPopoverPanelItemActiveClassName="text-slate-200 bg-sky-500 dark:bg-sky-600"
/>
</NxGraphToolbarItemGroup>
<NxGraphToolbarItemGroup className="pl-2 pr-0">
<NxGraphResetLayoutTool toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600" />
<NxGraphResetGraphTool toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600" />
<NxGraphShareTool
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
/>
</NxGraphToolbarItemGroup>
<NxGraphToolbarItemGroup className="pl-2 pr-0">
<NxGraphProjectModeTool
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
toolPopoverPanelItemActiveClassName="text-slate-200 bg-sky-500 dark:bg-sky-600"
/>
{handleEventResult.rendererConfig.mode === 'individual' && (
<>
<NxGraphGroupByFolderTool
toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolActiveClassName="bg-sky-500 dark:bg-sky-600 text-white dark:text-white"
/>
<NxGraphCollapseEdgesTool
toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolActiveClassName="bg-sky-500 dark:bg-sky-600 text-white dark:text-white"
toolDisabledClassName="opacity-50 cursor-not-allowed hover:bg-transparent hover:text-slate-400 dark:hover:text-slate-600 border-slate-300 dark:border-slate-600"
/>
</>
)}
</NxGraphToolbarItemGroup>
<NxGraphToolbarItemGroup last className="pl-2">
<NxGraphShowModeTool
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
toolPopoverPanelItemActiveClassName="text-slate-200 bg-sky-500 dark:bg-sky-600"
/>
</NxGraphToolbarItemGroup>
</NxGraphToolbar>
<NxGraphEmpty className="z-40 flex items-center gap-2 bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300">
<ArrowLeftCircleIcon className="h-6 w-6 text-slate-600 dark:text-slate-400" />
<h4 className="text-slate-700 dark:text-slate-300">
No projects found for the current graph configuration.
</h4>
</NxGraphEmpty>
{handleEventResult.rendererConfig.platform === 'nx-console' ? (
<ProjectGraphControlsPanel handleEventResult={handleEventResult} />
) : (
<div className="absolute bottom-0 left-0 top-4 flex flex-col">
<div className="mb-2 ml-4 min-w-96 max-w-96">
<TabGroup>
<TabList className="flex rounded-lg bg-slate-100 p-1 dark:bg-slate-800">
<Tab
className={classNames(
'text-md flex-1 rounded-md px-3 py-1.5 font-medium transition-colors',
currentPath === '/projects'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
)}
>
Projects
</Tab>
<Tab
className={classNames(
'text-md flex-1 rounded-md px-3 py-1.5 font-medium transition-colors',
currentPath === '/tasks'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
)}
onClick={() => navigate('../tasks')}
>
Tasks
</Tab>
</TabList>
</TabGroup>
</div>
<div className="relative flex-1">
<ProjectGraphControlsPanel
handleEventResult={handleEventResult}
closable={false}
/>
</div>
</div>
)}
<NxGraphElementPanel
element={element}
panelContainerClassName="border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900"
panelHeaderClassName="border-slate-300 dark:border-slate-700"
panelContentContainerClassName="divide-slate-300 dark:divide-slate-700"
header={{
project: (element, { open, close }) => (
<NxGraphProjectNodePanelHeader
element={element}
open={open}
close={close}
badgeClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 uppercase"
elementNameClassName="text-slate-900 dark:text-slate-100"
closeButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
/>
),
'composite-project': (element, { open, close }) => (
<NxGraphCompositeProjectNodePanelHeader
element={element}
open={open}
close={close}
badgeClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 uppercase"
elementNameClassName="text-slate-900 dark:text-slate-100"
closeButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
/>
),
'project-edge': (element, { open, close }) => (
<NxGraphProjectEdgePanelHeader
element={element}
open={open}
close={close}
badgeClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 uppercase"
closeButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
/>
),
'composite-project-edge': (element, { open, close }) => (
<NxGraphCompositeProjectEdgePanelHeader
element={element}
open={open}
close={close}
badgeClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 uppercase"
closeButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
/>
),
}}
>
{{
project: (element) => (
<NxGraphProjectNodePanelContent
element={element}
sectionHeadingClassName="text-slate-900 dark:text-slate-100"
sectionTextClassName="text-slate-700 dark:text-slate-300"
tagBadgeClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600"
actionButtonClassName="bg-slate-100/60 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-100 border-slate-300 dark:border-slate-600"
cancelActionButtonClassName="bg-red-500 dark:bg-red-600 text-white hover:bg-red-600 dark:hover:bg-red-700 border-red-500 dark:border-red-600"
dependencyItemClassName="text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
dependentItemClassName="text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
emptyItemListClassName="text-slate-600 dark:text-slate-400"
traceAlgorithmButtonClassName="bg-slate-100/60 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-100 border-slate-300 dark:border-slate-600"
traceAlgorithmActiveButtonClassName="bg-sky-500 dark:bg-sky-600 text-white hover:bg-sky-600 dark:hover:bg-sky-700 border-sky-500 dark:border-sky-600"
traceableProjectItemClassName="text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 justify-between"
traceableProjectSelectedItemClassName="bg-sky-500/10 dark:bg-sky-600/10 text-slate-900 dark:text-slate-100"
traceEndFilterInputClassName="bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:border-sky-500 dark:focus:border-sky-400"
onViewProjectDetailsClick={() =>
onViewProjectDetailsClick(element)
}
/>
),
'project-edge': (element) => (
<NxGraphProjectEdgePanelContent
element={element}
sectionHeadingClassName="text-slate-900 dark:text-slate-100"
sectionTextClassName="text-slate-700 dark:text-slate-300"
projectNameClassName="text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
sectionListContainerClassName="border-slate-200 dark:border-slate-700"
sectionListSectionClassName="bg-slate-50 dark:bg-slate-800"
sectionListHeaderClassName="text-slate-700 dark:text-slate-300 bg-slate-200 dark:bg-slate-800"
sectionListHeaderLabelClassName="text-slate-600 dark:text-slate-400"
sectionListItemsClassName="divide-slate-200 dark:divide-slate-700"
sectionListItemClassName="bg-slate-50 dark:bg-slate-700"
sectionListItemLabelClassName="text-slate-900 dark:text-slate-100"
/>
),
'composite-project': (element) => (
<NxGraphCompositeProjectNodePanelContent
element={element}
sectionHeadingClassName="text-slate-900 dark:text-slate-100"
sectionTextClassName="text-slate-700 dark:text-slate-300"
actionButtonClassName="bg-slate-100/60 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-100 border-slate-300 dark:border-slate-600"
cancelActionButtonClassName="bg-red-500 dark:bg-red-600 text-white hover:bg-red-600 dark:hover:bg-red-700 border-red-500 dark:border-red-600"
confirmActionButtonClassName="bg-sky-500 dark:bg-sky-600 text-white hover:bg-sky-600 dark:hover:bg-sky-700 border-sky-500 dark:border-sky-600"
multiselectContainerClassName="text-slate-700 dark:text-slate-300"
multiselectFilterInputClassName="bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 focus:border-sky-500 dark:focus:border-sky-400"
multiselectEmptyStateClassName="text-slate-600 dark:text-slate-400"
multiselectListItemClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
multiselectCheckboxClassName="border-slate-300 dark:border-slate-600 accent-sky-500"
multiselectLabelClassName="text-slate-700 dark:text-slate-300"
multiselectSectionHeaderClassName="text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-700"
/>
),
'composite-project-edge': (element) => (
<NxGraphCompositeProjectEdgePanelContent
element={element}
sectionHeadingClassName="text-slate-900 dark:text-slate-100"
sectionTextClassName="text-slate-700 dark:text-slate-300"
projectNameClassName="text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
sectionListContainerClassName="border-slate-200 dark:border-slate-700"
sectionListSectionClassName="bg-slate-50 dark:bg-slate-800"
sectionListHeaderClassName="text-slate-700 dark:text-slate-300 bg-slate-200 dark:bg-slate-800"
sectionListHeaderLabelClassName="text-slate-600 dark:text-slate-400"
sectionListItemsClassName="divide-slate-200 dark:divide-slate-700"
sectionListItemClassName="bg-slate-50 dark:bg-slate-700"
sectionListItemLabelClassName="text-slate-900 dark:text-slate-100"
/>
),
}}
</NxGraphElementPanel>
</div>
</div>
);
}
function ProjectGraphControlsPanel({
handleEventResult,
closable = true,
}: {
handleEventResult: ProjectGraphHandleEventResult;
closable?: boolean;
}) {
return (
<NxGraphPanel
anchor="left"
closable={closable}
initialOpen
panelContainerClassName="border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900"
panelHeaderClassName="border-slate-300 dark:border-slate-700"
panelCloseButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
panelContentContainerClassName="divide-slate-300 dark:divide-slate-700"
panelTriggerButtonClassName="border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
>
{handleEventResult.state.type === 'focused' ? (
<NxGraphPanelItemGroup>
<NxGraphDependencyDistanceControl
controlLabelClassName="text-slate-900 dark:text-slate-100"
controlDescriptionClassName="text-slate-600 dark:text-slate-400"
controlInputClassName="border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:border-sky-500 dark:focus:border-sky-400 focus:ring-sky-500/20 dark:focus:ring-sky-400/20"
removeFocusButtonClassName="bg-red-500 dark:bg-red-600 text-white"
/>
</NxGraphPanelItemGroup>
) : null}
<NxGraphPanelItemGroup last>
<NxGraphProjectListControl
projectItemClassName={({ rendered }) =>
rendered
? 'text-slate-900 dark:text-slate-50 hover:bg-slate-100 dark:hover:bg-slate-700'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
}
projectItemSelectIconClassName={() =>
'hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-sky-500 dark:hover:text-sky-400'
}
searchInputClassName="bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:border-transparent focus:ring-2 focus:ring-sky-500/20 dark:focus:ring-sky-400/20 focus:outline-none data-[focus]:border-sky-500 dark:data-[focus]:border-sky-400 data-[focus]:ring-2 data-[focus]:ring-sky-500/20 dark:data-[focus]:ring-sky-400/20"
searchInputClearButtonClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-white border-slate-300 dark:border-slate-600"
projectListEmptyClassName="text-slate-600 dark:text-slate-400"
projectSectionHeaderClassName="text-slate-900 dark:text-slate-100"
projectItemCheckboxClassName={({ rendered }) =>
classNames(
'border-slate-300 dark:border-slate-600',
rendered ? 'bg-sky-500' : ''
)
}
projectItemTextClassName={({ rendered }) =>
rendered
? 'text-slate-900 dark:text-slate-100'
: 'text-slate-400 dark:text-slate-400'
}
/>
</NxGraphPanelItemGroup>
</NxGraphPanel>
);
}
@@ -0,0 +1,668 @@
// nx-ignore-next-line
import type {
GraphError,
ProjectGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
import type { ProjectGraphProjectNode } from '@nx/devkit';
import { useTaskGraphContext, NxGraphTaskGraphProvider } from '@nx/graph/tasks';
import {
getExternalApiService,
getProjectGraphDataService,
useEnvironmentConfig,
useRouteConstructor,
} from '@nx/graph-shared';
import { useRankDir, useTheme } from '@nx/graph-internal-ui-render-config';
import {
useNavigate,
useRouteLoaderData,
useSearchParams,
} from 'react-router-dom';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { ErrorToast } from '@nx/graph-ui-common';
import {
NxGraphDebuggerPanel,
NxGraphEmpty,
NxGraphProjectListControl,
NxGraphCustomProjectItem,
NxGraphDropdownMultiselect,
NxGraphTaskNodePanelContent,
useElementPanel,
NxGraphToolbar,
NxGraphToolbarItemGroup,
NxGraphThemeTool,
NxGraphRankDirTool,
NxGraphResetLayoutTool,
NxGraphResetGraphTool,
NxGraphShareTool,
NxGraphGroupByProjectTool,
NxGraphPanel,
NxGraphElementPanel,
NxGraphTaskNodePanelHeader,
} from '@nx/graph/ui';
import {
ArrowLeftCircleIcon,
DocumentMagnifyingGlassIcon,
PlayIcon,
} from '@heroicons/react/24/outline';
import { useCurrentPath } from '../hooks/use-current-path';
import { createTaskName } from '../util';
import classNames from 'classnames';
import { ElementData, RenderPlatform } from '@nx/graph';
import { Tab, TabGroup, TabList } from '@headlessui/react';
export function TasksShell() {
const environmentConfig = useEnvironmentConfig();
const [errors, setErrors] = useState<GraphError[] | undefined>(undefined);
const renderPlatform = useMemo(
() =>
environmentConfig.environment === 'nx-console' ? 'nx-console' : 'nx',
[environmentConfig.environment]
);
const { errors: routerErrors } = useRouteLoaderData(
'selectedWorkspace'
) as ProjectGraphClientResponse;
const tasksRouteData = useRouteLoaderData('tasks') as TaskGraphClientResponse;
const allTasksRouteData = useRouteLoaderData(
'tasksAll'
) as TaskGraphClientResponse;
const taskGraphError = useMemo(
() => allTasksRouteData?.error || tasksRouteData?.error,
[allTasksRouteData, tasksRouteData]
);
useLayoutEffect(() => {
const allErrors: GraphError[] = [];
if (routerErrors) {
allErrors.push(...routerErrors);
}
if (taskGraphError) {
allErrors.push({
message: taskGraphError,
pluginName: 'graph',
cause: 'TaskGraphClientResponse error',
name: 'graph',
stack: '',
});
}
if (allErrors.length === 0) return;
setErrors(allErrors);
}, [routerErrors, taskGraphError]);
return (
<>
<NxGraphTaskGraphProvider renderPlatform={renderPlatform}>
<TasksShellInner />
</NxGraphTaskGraphProvider>
<ErrorToast errors={errors} />
</>
);
}
function TasksShellInner() {
const graphContext = useTaskGraphContext();
const {
containerRef,
send,
orchestrator,
sendRendererConfigEvent,
restoreGraphState,
serializedGraphState,
handleEventResult,
} = graphContext;
const navigate = useNavigate();
const { currentPath } = useCurrentPath();
const { resolvedTheme, theme, setTheme } = useTheme();
const { rankDir, setRankDir } = useRankDir();
const environmentConfig = useEnvironmentConfig();
const [searchParams, setSearchParams] = useSearchParams();
const createRoute = useRouteConstructor();
const isAllRoute = useMemo(() => currentPath === '/tasks/all', [currentPath]);
const selectedWorkspaceLoaderData = useRouteLoaderData(
'selectedWorkspace'
) as ProjectGraphClientResponse & { targets: string[] };
const tasksRouteData = useRouteLoaderData('tasks') as TaskGraphClientResponse;
const allTasksRouteData = useRouteLoaderData(
'tasksAll'
) as TaskGraphClientResponse;
const tasksData = useMemo(() => {
return allTasksRouteData || tasksRouteData;
}, [allTasksRouteData, tasksRouteData]);
const selectedTargets = useMemo(() => {
const targetsParam = searchParams.get('targets');
return targetsParam ? targetsParam.split(' ').filter(Boolean) : [];
}, [searchParams]);
const allProjectsWithTargetsAndNoErrors = useMemo(
() =>
selectedWorkspaceLoaderData.projects.filter(
(project) =>
selectedTargets.length > 0 &&
selectedTargets.some((target) =>
project.data.targets?.hasOwnProperty(target)
) &&
!tasksData.error // If there's a global error, exclude all projects
),
[selectedWorkspaceLoaderData.projects, selectedTargets, tasksData.error]
);
const selectedProjects = useMemo(
() =>
isAllRoute
? allProjectsWithTargetsAndNoErrors
: selectedWorkspaceLoaderData.projects.filter((project) =>
(searchParams.get('projects') ?? '')
.split(' ')
.includes(project.name)
),
[allProjectsWithTargetsAndNoErrors, searchParams, isAllRoute]
);
const selectedProjectNames = useMemo(
() => selectedProjects.map((project) => project.name),
[selectedProjects]
);
const graphState = searchParams.get('graph');
const [element] = useElementPanel<
ElementData.TaskNode | ElementData.CompositeTaskNode
>(orchestrator);
useEffect(() => {
if (!orchestrator) return;
send({
type: 'initGraph',
projects: selectedWorkspaceLoaderData.projects,
taskGraph: tasksData.taskGraph,
});
}, [orchestrator]);
useEffect(() => {
if (!orchestrator) return;
send({
type: 'mergeGraph',
projects: selectedWorkspaceLoaderData.projects,
taskGraph: tasksData.taskGraph,
});
}, [orchestrator, tasksData.taskGraph]);
useEffect(() => {
sendRendererConfigEvent({ type: 'themeChange', theme: resolvedTheme });
}, [resolvedTheme]);
useEffect(() => {
sendRendererConfigEvent({ type: 'rankDirChange', rankDir });
}, [rankDir]);
useEffect(() => {
if (!orchestrator) return;
const taskIds = selectedProjects.flatMap((project) =>
selectedTargets.map((target) => {
const resolvedConfiguration =
project.data.targets?.[target]?.defaultConfiguration;
return createTaskName(project.name, target, resolvedConfiguration);
})
);
send({ type: 'show', taskIds });
}, [selectedProjects, selectedTargets, send, orchestrator]);
useEffect(() => {
if (!graphState || !orchestrator || graphState === serializedGraphState) {
return;
}
const result = restoreGraphState(graphState);
if (!result) return;
if (result.rendererConfig.rankDir !== rankDir) {
setRankDir(result.rendererConfig.rankDir);
}
if (result.rendererConfig.theme !== resolvedTheme) {
setTheme(result.rendererConfig.theme);
}
}, [graphState, orchestrator]);
useEffect(() => {
setSearchParams(
(params) => {
const currentGraphParams = params.get('graph');
// this means serialization went wrong
if (serializedGraphState === null && currentGraphParams) {
params.delete('graph');
return params;
}
if (
serializedGraphState &&
currentGraphParams !== serializedGraphState
) {
params.set('graph', serializedGraphState);
}
return params;
},
{ preventScrollReset: true }
);
}, [serializedGraphState]);
const onTargetsSelectionChange = (selectedIds: string[]) => {
const newSearchParams = new URLSearchParams(searchParams);
// remove selected projects when targets change
newSearchParams.delete('projects');
if (selectedIds.length > 0) {
newSearchParams.set('targets', selectedIds.join(' '));
} else {
newSearchParams.delete('targets');
}
setSearchParams((prev) => {
prev.delete('projects');
if (selectedIds.length > 0) {
prev.set('targets', selectedIds.join(' '));
} else {
prev.delete('targets');
}
return prev;
});
};
const onToggleProject = (projectName: string, isSelected: boolean) => {
if (isSelected) {
const newSelectedProjects = selectedProjectNames.filter(
(selectedProject) => selectedProject !== projectName
);
const newParams = new URLSearchParams(searchParams);
if (newSelectedProjects.length === 0) {
newParams.delete('projects');
} else {
newParams.set('projects', newSelectedProjects.join(' '));
}
navigate(
createRoute(
{ pathname: '/tasks', search: newParams.toString() },
() => newParams
)
);
return;
}
const newSelectedProjects = [...selectedProjectNames, projectName];
const allProjectsSelected =
newSelectedProjects.length === allProjectsWithTargetsAndNoErrors.length;
const newParams = new URLSearchParams(searchParams);
if (allProjectsSelected) {
newParams.delete('projects');
} else {
newParams.set('projects', newSelectedProjects.join(' '));
}
navigate(
createRoute(
{
pathname: allProjectsSelected ? '/tasks/all' : '/tasks',
search: newParams.toString(),
},
() => newParams
)
);
};
return (
<div className="flex h-full w-full flex-col">
{environmentConfig.appConfig.showDebugger ? (
<NxGraphDebuggerPanel
position="top"
containerClassName="static bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300 border-slate-300 dark:border-slate-800"
expandButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600"
copyButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600"
textareaClassName="bg-slate-50 dark:bg-slate-800"
/>
) : null}
<div className="relative h-full w-full bg-white dark:bg-slate-900">
<div
ref={containerRef}
className="h-full w-full bg-white dark:bg-slate-900"
id="cytoscape-graph"
></div>
<NxGraphToolbar toolbarClassName="border-slate-300 dark:border-slate-700 divide-slate-300 dark:divide-slate-700 z-50 divide-x">
<NxGraphToolbarItemGroup className="pr-0">
<NxGraphThemeTool
theme={theme}
setTheme={setTheme}
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
toolPopoverPanelItemActiveClassName="text-slate-200 bg-sky-500 dark:bg-sky-600"
/>
<NxGraphRankDirTool
onRankDirChange={setRankDir}
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
toolPopoverPanelItemActiveClassName="text-slate-200 bg-sky-500 dark:bg-sky-600"
/>
</NxGraphToolbarItemGroup>
<NxGraphToolbarItemGroup className="pl-2 pr-0">
<NxGraphGroupByProjectTool
toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolActiveClassName="bg-sky-500 dark:bg-sky-600 text-white dark:text-white"
/>
</NxGraphToolbarItemGroup>
<NxGraphToolbarItemGroup last className="pl-2">
<NxGraphResetLayoutTool toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600" />
<NxGraphResetGraphTool toolClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600" />
<NxGraphShareTool
toolPopoverButtonClassName="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600"
toolPopoverPanelClassName="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 z-50"
toolPopoverPanelItemClassName="hover:bg-sky-500 dark:hover:bg-sky-600 hover:text-slate-200 dark:text-slate-300"
/>
</NxGraphToolbarItemGroup>
</NxGraphToolbar>
<NxGraphEmpty className="z-30 flex items-center gap-2 bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300">
<ArrowLeftCircleIcon className="h-6 w-6 text-slate-600 dark:text-slate-400" />
<h4 className="text-slate-700 dark:text-slate-300">
No tasks found for the current graph configuration.
</h4>
</NxGraphEmpty>
{handleEventResult.rendererConfig.platform === 'nx-console' ? (
<TaskGraphControlsPanel
targets={selectedWorkspaceLoaderData.targets}
selectedTargets={selectedTargets}
selectedProjects={selectedProjectNames}
allProjects={allProjectsWithTargetsAndNoErrors}
error={tasksData.error}
onTargetsSelectionChange={onTargetsSelectionChange}
onToggleProject={onToggleProject}
/>
) : (
<div className="absolute bottom-0 left-0 top-4 z-50 flex flex-col">
<TabGroup className="mb-2 ml-4 min-w-96 max-w-96">
<TabList className="flex rounded-lg bg-slate-100 p-1 dark:bg-slate-800">
<Tab
className={classNames(
'text-md flex-1 rounded-md px-3 py-1.5 font-medium transition-colors',
currentPath === '/projects'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
)}
onClick={() => navigate('../projects')}
>
Projects
</Tab>
<Tab
className={classNames(
'text-md flex-1 rounded-md px-3 py-1.5 font-medium transition-colors',
currentPath === '/tasks'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
)}
>
Tasks
</Tab>
</TabList>
</TabGroup>
<div className="relative flex-1">
<TaskGraphControlsPanel
closable={false}
targets={selectedWorkspaceLoaderData.targets}
selectedTargets={selectedTargets}
selectedProjects={selectedProjectNames}
allProjects={allProjectsWithTargetsAndNoErrors}
error={tasksData.error}
onTargetsSelectionChange={onTargetsSelectionChange}
onToggleProject={onToggleProject}
/>
</div>
</div>
)}
<NxGraphElementPanel
element={element}
panelContainerClassName="border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900"
panelHeaderClassName="border-slate-300 dark:border-slate-700"
panelContentContainerClassName="divide-slate-300 dark:divide-slate-700"
header={{
task: (element, { open, close }) => (
<NxGraphTaskNodePanelHeader
element={element}
open={open}
close={close}
elementNameClassName="text-slate-900 dark:text-slate-100"
closeButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
taskFlagBadgeClassName="bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600"
/>
),
}}
>
{{
task: (element) => (
<TaskNodeDetails
element={element}
platform={handleEventResult.rendererConfig.platform}
/>
),
}}
</NxGraphElementPanel>
</div>
</div>
);
}
interface TaskNodeDetailsProps {
platform: RenderPlatform;
element: ElementData.TaskNode;
}
function TaskNodeDetails({ element, platform }: TaskNodeDetailsProps) {
const projectGraphDataService = getProjectGraphDataService();
const externalApiService = getExternalApiService();
const routeConstructor = useRouteConstructor();
const navigate = useNavigate();
const [inputs, setInputs] = useState<Record<string, string[]> | null>(null);
const [isLoadingInputs, setIsLoadingInputs] = useState(true);
useEffect(() => {
// Only fetch if the service has the method
if (!projectGraphDataService.getExpandedTaskInputs) return;
projectGraphDataService
.getExpandedTaskInputs(element.name)
.then((expandedInputs) => {
setInputs(expandedInputs);
})
.catch((error) => {
console.error('Failed to fetch task inputs:', error);
})
.finally(() => {
setIsLoadingInputs(false);
});
}, [element.name, projectGraphDataService]);
const onViewProjectDetailsClick = () => {
if (platform === 'nx-dev') return;
const projectName = element.project.name;
const targetName = element.task.target.target;
if (platform === 'nx-console') {
return externalApiService.postEvent({
type: 'open-project-config',
payload: { projectName, targetName },
});
}
navigate(
routeConstructor(
{
pathname: `/project-details/${encodeURIComponent(projectName)}`,
search: `expanded=${targetName}`,
},
false
)
);
};
const onRunTaskClick = () => {
if (platform !== 'nx-console') return;
externalApiService.postEvent({
type: 'run-task',
payload: { taskId: element.id, takeName: element.name },
});
};
return (
<NxGraphTaskNodePanelContent
element={element}
inputs={inputs}
isLoadingInputs={isLoadingInputs}
sectionHeadingClassName="text-slate-900 dark:text-slate-100"
sectionTextClassName="text-slate-700 dark:text-slate-300"
actionButtonClassName="bg-slate-100/60 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-100 border-slate-300 dark:border-slate-600"
sectionListContainerClassName="border-slate-200 dark:border-slate-700"
sectionListSectionClassName="bg-slate-50 dark:bg-slate-800"
sectionListHeaderClassName="text-slate-700 dark:text-slate-300 bg-slate-200 dark:bg-slate-800"
sectionListHeaderLabelClassName="text-slate-600 dark:text-slate-400"
sectionListItemsClassName="divide-slate-200 dark:divide-slate-600"
sectionListItemClassName="bg-slate-50 dark:bg-slate-700"
sectionListItemLabelClassName="text-slate-900 dark:text-slate-100"
loadingSkeletonHeaderClassName="bg-slate-200 dark:bg-slate-600"
loadingSkeletonItemClassName="bg-slate-100 dark:bg-slate-700"
loadingSkeletonListClassName="bg-slate-50 dark:bg-slate-800"
>
{({ NxGraphActionButton }) => (
<>
<NxGraphActionButton onClick={onViewProjectDetailsClick}>
<DocumentMagnifyingGlassIcon className="h-4 w-4" />
<span className="text-sm">View in Project Details</span>
</NxGraphActionButton>
{platform === 'nx-console' ? (
<NxGraphActionButton onClick={onRunTaskClick}>
<PlayIcon className="h-4 w-4" />
<span className="text-sm">Run task</span>
</NxGraphActionButton>
) : null}
</>
)}
</NxGraphTaskNodePanelContent>
);
}
interface TaskGraphControlsPanelProps {
targets: string[];
selectedTargets: string[];
selectedProjects: string[];
allProjects: ProjectGraphProjectNode[];
closable?: boolean;
error?: string;
onTargetsSelectionChange: (selectedIds: string[]) => void;
onToggleProject: (projectName: string, isSelected: boolean) => void;
}
function TaskGraphControlsPanel({
targets,
selectedTargets,
selectedProjects,
closable = true,
allProjects,
error = '',
onTargetsSelectionChange,
onToggleProject,
}: TaskGraphControlsPanelProps) {
return (
<NxGraphPanel
anchor="left"
closable={closable}
initialOpen
panelContainerClassName="border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900"
panelHeaderClassName="border-slate-300 dark:border-slate-700"
panelCloseButtonClassName="hover:bg-slate-100 dark:hover:bg-slate-700"
panelContentContainerClassName="divide-slate-300 dark:divide-slate-700"
panelTriggerButtonClassName="border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<NxGraphDropdownMultiselect
items={targets.map((target) => ({ id: target, name: target }))}
selectedIds={selectedTargets}
onSelectionChange={onTargetsSelectionChange}
containerClassName="bg-white dark:bg-slate-800"
triggerClassName="bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-600"
triggerPlaceholderClassName="text-slate-500 dark:text-slate-400"
triggerChevronClassName="text-slate-500 dark:text-slate-400"
selectedChipClassName="bg-sky-100 dark:bg-sky-800 text-sky-900 dark:text-sky-100 border-sky-200 dark:border-sky-700"
selectedCountClassName="text-slate-700 dark:text-slate-300"
chipRemoveButtonClassName="text-sky-700 dark:text-sky-300 hover:text-sky-900 dark:hover:text-sky-100"
dropdownClassName="bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600"
dropdownContentClassName="bg-white dark:bg-slate-800"
multiselectContainerClassName="bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600"
multiselectFilterInputClassName="bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400"
multiselectEmptyStateClassName="text-slate-500 dark:text-slate-400"
multiselectListClassName="bg-white dark:bg-slate-800"
multiselectListItemClassName="hover:bg-slate-50 dark:hover:bg-slate-700"
multiselectCheckboxClassName="text-sky-600 dark:text-sky-400"
multiselectLabelClassName="text-slate-900 dark:text-slate-100"
/>
<NxGraphProjectListControl
projects={allProjects.map((p) => ({
type: p.type,
id: p.data.name,
name: p.data.name,
selected: selectedProjects.includes(p.name),
rendered: false,
affected: false,
}))}
projectListEmptyClassName="text-slate-500 dark:text-slate-400"
projectSectionHeaderClassName="text-slate-700 dark:text-slate-300"
searchInputClassName="bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400"
>
{(props) => (
<NxGraphCustomProjectItem
project={props.project}
error={error}
onToggle={() => {
onToggleProject(props.project.name, props.project.selected);
}}
projectItemClassName={({ selected }) =>
classNames(
'hover:bg-slate-100 dark:hover:bg-slate-700',
selected ? 'opacity-100' : 'opacity-70'
)
}
projectNameClassName="text-slate-900 dark:text-slate-100 dark:hover:bg-slate-700"
/>
)}
</NxGraphProjectListControl>
</NxGraphPanel>
);
}
@@ -0,0 +1,14 @@
import { useRouteError } from 'react-router-dom';
export function TasksSidebarErrorBoundary() {
let error = useRouteError();
console.error(error);
return (
<div className="mt-8 px-4">
<h2 className="mt-8 border-b border-solid border-slate-200/10 text-lg font-light text-slate-400 dark:text-slate-500">
Error
</h2>
<p>There was a problem loading your task graph.</p>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { createBrowserRouter, createHashRouter } from 'react-router-dom';
import { getRoutesForEnvironment } from './routes';
import { getEnvironmentConfig } from '@nx/graph-shared';
let router;
export function getRouter() {
if (!router) {
const environmentConfig = getEnvironmentConfig();
let routerCreate = createBrowserRouter;
if (
environmentConfig.localMode === 'build' ||
environmentConfig.environment === 'nx-console'
) {
routerCreate = createHashRouter;
}
router = routerCreate(getRoutesForEnvironment());
}
return router;
}
@@ -0,0 +1,37 @@
import { matchRoutes, useLocation } from 'react-router-dom';
import { getRoutesForEnvironment } from '../routes';
import { useState } from 'react';
import { getEnvironmentConfig } from '@nx/graph-shared';
export const useCurrentPath = () => {
const [lastLocation, setLastLocation] = useState<string>();
const [lastPath, setLastPath] = useState<{
workspace: string;
currentPath: string;
}>();
const location = useLocation();
if (location.pathname === lastLocation) {
return lastPath;
}
setLastLocation(location.pathname);
const route = matchRoutes(getRoutesForEnvironment(), location).at(-1);
const { environment } = getEnvironmentConfig();
let currentPath: { workspace: string; currentPath: string };
// if using dev routes, remove first segment for workspace
if (environment === 'dev') {
currentPath = {
workspace: route.pathname.split('/')[1],
currentPath: `/${route.pathname.split('/').slice(2).join('/')}`,
};
} else {
currentPath = { workspace: 'local', currentPath: route.pathname };
}
setLastPath(currentPath);
return currentPath;
};
+20
View File
@@ -0,0 +1,20 @@
// nx-ignore-next-line
import type {
ProjectGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
export interface ProjectGraphService {
getHash: () => Promise<string>;
getProjectGraph: (url: string) => Promise<ProjectGraphClientResponse>;
getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>;
setTaskInputsUrl?: (url: string) => void;
getExpandedTaskInputs?: (taskId: string) => Promise<Record<string, string[]>>;
getSourceMaps?: (
url: string
) => Promise<Record<string, Record<string, string[]>>>;
}
export interface Environment {
environment: 'dev' | 'watch' | 'release';
}
+349
View File
@@ -0,0 +1,349 @@
import { redirect, RouteObject, json, LoaderFunction } from 'react-router-dom';
// nx-ignore-next-line
import type {
GraphError,
ProjectGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
// nx-ignore-next-line
import type { ProjectGraphProjectNode } from 'nx/src/config/project-graph';
import {
getEnvironmentConfig,
getProjectGraphDataService,
} from '@nx/graph-shared';
import { TasksSidebarErrorBoundary } from './feature-tasks/tasks-sidebar-error-boundary';
import { ProjectDetailsPage } from '@nx/graph-internal-project-details';
import { ErrorBoundary } from './ui-components/error-boundary';
import { taskGraphClientCache } from './task-graph-client-cache';
import { ProjectsShell } from './feature-projects/projects-shell';
import { TasksShell } from './feature-tasks/tasks-shell';
import { GraphSerializableState, GraphStateSerializer } from '@nx/graph';
const { appConfig } = getEnvironmentConfig();
const projectGraphDataService = getProjectGraphDataService();
export function getRoutesForEnvironment() {
if (getEnvironmentConfig().environment === 'dev') {
return devRoutes;
} else {
return releaseRoutes;
}
}
const workspaceDataLoader = async (
selectedWorkspaceId: string,
requestFull = false
) => {
const workspaceInfo = appConfig.workspaces.find(
(graph) => graph.id === selectedWorkspaceId
);
if (!workspaceInfo) {
throw new Error(`Workspace ${selectedWorkspaceId} not found`);
}
projectGraphDataService.setTaskInputsUrl?.(workspaceInfo.taskInputsUrl);
const projectGraph: ProjectGraphClientResponse =
await projectGraphDataService.getProjectGraph(
workspaceInfo.projectGraphUrl,
requestFull
);
const targetsSet = new Set<string>();
projectGraph.projects.forEach((project) => {
Object.keys(project.data.targets ?? {}).forEach((targetName) => {
targetsSet.add(targetName);
});
});
const targets = Array.from(targetsSet).sort((a, b) => a.localeCompare(b));
const sourceMaps = await sourceMapsLoader(selectedWorkspaceId);
return { ...projectGraph, targets, sourceMaps };
};
const sourceMapsLoader = async (selectedWorkspaceId: string) => {
const workspaceInfo = appConfig.workspaces.find(
(graph) => graph.id === selectedWorkspaceId
);
if (!workspaceInfo) {
throw new Error(`Workspace ${selectedWorkspaceId} not found`);
}
return await projectGraphDataService.getSourceMaps(
workspaceInfo.sourceMapsUrl
);
};
const tasksLoader: LoaderFunction = async ({ params, request }) => {
const selectedWorkspaceId =
params.selectedWorkspaceId ?? appConfig.defaultWorkspaceId;
const workspaceInfo = appConfig.workspaces.find(
(graph) => graph.id === selectedWorkspaceId
);
if (!workspaceInfo) {
throw new Error(`Workspace ${selectedWorkspaceId} not found`);
}
// bust the task graph cache if workspaceId changes
taskGraphClientCache.setWorkspace(selectedWorkspaceId);
const requestUrl = new URL(request.url);
const targetsParam = requestUrl.searchParams.get('targets');
const projectsParam = requestUrl.searchParams.get('projects');
const selectedTargets = targetsParam
? targetsParam.split(' ').filter(Boolean)
: [];
const requestedProjects = projectsParam
? projectsParam.split(' ').filter(Boolean)
: undefined;
// if no targets are selected, empty taskGraph response
if (selectedTargets.length === 0) {
return {
taskGraph: {
tasks: {},
dependencies: {},
continuousDependencies: {},
roots: [],
},
error: null,
};
}
const isAllRoute = requestUrl.pathname.endsWith('/all');
// if we don't request any projects and we're not on the "all" route,
// we return empty taskGraph response. consumers need to select projects
if (!requestedProjects && !isAllRoute) {
return {
taskGraph: {
tasks: {},
dependencies: {},
continuousDependencies: {},
roots: [],
},
error: null,
};
}
// Check if we have cached data for this exact combination
const { cached, missingTargets, missingProjects } =
taskGraphClientCache.getCached(selectedTargets, requestedProjects);
if (cached) return cached;
const response = await projectGraphDataService.getSpecificTaskGraph(
workspaceInfo.taskGraphUrl,
missingProjects,
missingTargets
);
if (response.error) return response;
// only merge if we don't have error
return taskGraphClientCache.mergeTaskGraph(
response,
missingTargets,
missingProjects
);
};
const projectDetailsLoader = async (
selectedWorkspaceId: string,
projectName: string
): Promise<{
hash: string;
project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>;
errors?: GraphError[];
connectedToCloud?: boolean;
disabledTaskSyncGenerators?: string[];
}> => {
const workspaceData = await workspaceDataLoader(selectedWorkspaceId);
const sourceMaps = await sourceMapsLoader(selectedWorkspaceId);
const project = workspaceData.projects.find(
(project) => project.name === projectName
);
if (!project) {
throw json({
id: 'project-not-found',
projectName,
errors: workspaceData.errors,
});
}
return {
hash: workspaceData.hash,
project,
sourceMap: sourceMaps[project.data.root],
errors: workspaceData.errors,
connectedToCloud: workspaceData.connectedToCloud,
disabledTaskSyncGenerators: workspaceData.disabledTaskSyncGenerators,
};
};
const tasksRouteObject: RouteObject = {
element: <TasksShell />,
errorElement: <TasksSidebarErrorBoundary />,
loader: tasksLoader,
shouldRevalidate: ({ currentParams, nextParams, currentUrl, nextUrl }) => {
// Always revalidate if workspace changes
if (
!currentParams.selectedWorkspaceId ||
currentParams.selectedWorkspaceId !== nextParams.selectedWorkspaceId
) {
return true;
}
// Revalidate if query parameters change (targets or projects)
const currentSearchParams = new URLSearchParams(currentUrl.search);
const nextSearchParams = new URLSearchParams(nextUrl.search);
const currentTargets = currentSearchParams.get('targets');
const nextTargets = nextSearchParams.get('targets');
const currentProjects = currentSearchParams.get('projects');
const nextProjects = nextSearchParams.get('projects');
return currentTargets !== nextTargets || currentProjects !== nextProjects;
},
};
const childRoutes: RouteObject[] = [
{
path: 'projects',
element: <ProjectsShell />,
loader: ({ request }) => {
const url = new URL(request.url);
if (!url.searchParams.has('rawGraph')) {
return null;
}
handleRawGraphQueryParam(url.searchParams);
return redirect(`${url.pathname}?${url.searchParams.toString()}`);
},
},
{
path: 'tasks',
id: 'tasks',
...tasksRouteObject,
},
{
path: 'tasks/all',
id: 'tasksAll',
...tasksRouteObject,
},
];
function handleRawGraphQueryParam(searchParams: URLSearchParams) {
const rawGraph = searchParams.get('rawGraph');
// nothing to do here, bail
if (!rawGraph) return;
try {
const rawGraphJson: {
config: Record<string, unknown>;
state?: Record<string, unknown>;
} = JSON.parse(rawGraph);
searchParams.delete('rawGraph');
searchParams.set(
'graph',
GraphStateSerializer.serialize({
c: rawGraphJson.config,
s: rawGraphJson.state,
} as Omit<GraphSerializableState<any>, 'v'>)
);
} catch (err) {
console.error('Graph Client: error during rawGraph handling', err);
return;
}
}
export const devRoutes: RouteObject[] = [
{
path: '/',
errorElement: <ErrorBoundary />,
children: [
{
index: true,
loader: ({ request }) => {
const { searchParams } = new URL(request.url);
handleRawGraphQueryParam(searchParams);
return redirect(
`/${
appConfig.defaultWorkspaceId
}/projects?${searchParams.toString()}`
);
},
},
{
path: ':selectedWorkspaceId',
id: 'selectedWorkspace',
shouldRevalidate: ({ currentParams, nextParams }) => {
return (
currentParams.selectedWorkspaceId !== nextParams.selectedWorkspaceId
);
},
loader: async ({ params }) => {
const selectedWorkspaceId =
params.selectedWorkspaceId ?? appConfig.defaultWorkspaceId;
return workspaceDataLoader(selectedWorkspaceId, true);
},
children: childRoutes,
},
{
path: ':selectedWorkspaceId/project-details/:projectName',
id: 'selectedProjectDetails',
element: <ProjectDetailsPage />,
loader: async ({ params }) => {
const projectName = params.projectName;
return projectDetailsLoader(params.selectedWorkspaceId, projectName);
},
},
],
},
];
export const releaseRoutes: RouteObject[] = [
{
path: '/',
id: 'selectedWorkspace',
loader: async () => {
const selectedWorkspaceId = appConfig.defaultWorkspaceId;
return workspaceDataLoader(selectedWorkspaceId, true);
},
shouldRevalidate: () => {
return false;
},
children: [
{
index: true,
loader: ({ request }) => {
const { searchParams } = new URL(request.url);
handleRawGraphQueryParam(searchParams);
return redirect(`/projects?${searchParams.toString()}`);
},
},
...childRoutes,
],
errorElement: <ErrorBoundary />,
},
{
path: 'project-details/:projectName',
id: 'selectedProjectDetails',
element: <ProjectDetailsPage />,
errorElement: <ErrorBoundary />,
loader: async ({ params }) => {
const projectName = params.projectName;
return projectDetailsLoader(appConfig.defaultWorkspaceId, projectName);
},
},
];
@@ -0,0 +1,153 @@
// nx-ignore-next-line
import type { TaskGraphClientResponse } from 'nx/src/command-line/graph/graph';
class TaskGraphClientCache {
private cached: TaskGraphClientResponse | null = null;
private currentWorkspaceId: string | null = null;
private fetchedSet = new Set<string>();
setWorkspace(workspaceId: string) {
if (this.currentWorkspaceId === workspaceId) return;
this.currentWorkspaceId = workspaceId;
this.clear();
}
getCached(targets: string[], requestedProjects?: string[]) {
// if we don't have cached result yet, we'll always fetch
if (!this.cached) {
return {
cached: null,
missingTargets: targets,
missingProjects: requestedProjects,
};
}
// this means we're fetching "all" for all targets
if (!requestedProjects || requestedProjects.length === 0) {
const missingTargets: string[] = [];
for (const target of targets) {
const targetWithAllKey = `${target}:all`;
// if we already fetch for this target, skip
if (this.fetchedSet.has(targetWithAllKey)) continue;
missingTargets.push(target);
}
// no missing targets needed, we return the cached
return {
cached: missingTargets.length === 0 ? this.cached : null,
missingTargets,
};
}
const missingTargetsSet = new Set<string>();
const missingProjectsSet = new Set<string>();
for (const target of targets) {
for (const project of requestedProjects) {
const key = `${target}:${project}`;
if (this.fetchedSet.has(key)) continue;
missingTargetsSet.add(target);
missingProjectsSet.add(project);
}
}
if (missingTargetsSet.size === 0 && missingProjectsSet.size === 0) {
return { cached: this.cached, missingTargets: [] };
}
return {
cached: null,
missingTargets: Array.from(missingTargetsSet),
missingProjects: Array.from(missingProjectsSet),
};
}
mergeTaskGraph(
response: TaskGraphClientResponse,
fetchedTargets: string[],
fetchedProjects?: string[]
) {
// populate the fetchedSet with targets and projects that were used
// in the request
if (!fetchedProjects) {
fetchedTargets.forEach((fetchedTarget) => {
this.fetchedSet.add(`${fetchedTarget}:all`);
});
} else {
for (const fetchedTarget of fetchedTargets) {
for (const fetchedProject of fetchedProjects) {
this.fetchedSet.add(`${fetchedTarget}:${fetchedProject}`);
}
}
}
const mergedTasks = {
...this.cached?.taskGraph.tasks,
...response.taskGraph.tasks,
};
const rootsSet = new Set<string>(this.cached?.taskGraph.roots || []);
for (const root of response.taskGraph.roots) {
rootsSet.add(root);
}
const mergedDependencies = this.mergeStringCollectionRecord(
this.cached?.taskGraph.dependencies,
response.taskGraph.dependencies
);
const mergedContinuousDependencies = this.mergeStringCollectionRecord(
this.cached?.taskGraph.continuousDependencies,
response.taskGraph.continuousDependencies
);
const mergedTaskGraph = {
tasks: mergedTasks,
roots: Array.from(rootsSet),
dependencies: mergedDependencies,
continuousDependencies: mergedContinuousDependencies,
};
const mergedPlans = this.mergeStringCollectionRecord(
this.cached?.plans,
response.plans
);
this.cached = {
taskGraph: mergedTaskGraph,
plans: mergedPlans,
error: response.error || this.cached?.error || null,
};
return this.cached;
}
clear() {
this.cached = null;
this.fetchedSet.clear();
}
private mergeStringCollectionRecord(
cachedRecord?: Record<string, string[]>,
responseRecord?: Record<string, string[]>
) {
const mergedRecord: Record<string, string[]> = {};
for (const taskId in cachedRecord) {
mergedRecord[taskId] = cachedRecord[taskId];
}
for (const taskId in responseRecord) {
const collectionSet = new Set([
...(mergedRecord[taskId] || []),
...(responseRecord[taskId] || []),
]);
mergedRecord[taskId] = Array.from(collectionSet);
}
return mergedRecord;
}
}
export const taskGraphClientCache = new TaskGraphClientCache();
@@ -0,0 +1,73 @@
import { ProjectDetailsHeader } from '@nx/graph-internal-project-details';
import {
fetchProjectGraph,
getProjectGraphDataService,
useEnvironmentConfig,
usePoll,
} from '@nx/graph-shared';
import {
isRouteErrorResponse,
useParams,
useRouteError,
} from 'react-router-dom';
import { ErrorPage } from './error-page';
export function ErrorBoundary() {
let error = useRouteError();
console.error(error);
const { environment, appConfig, watch } = useEnvironmentConfig();
const projectGraphDataService = getProjectGraphDataService();
const params = useParams();
const hasErrorData =
isRouteErrorResponse(error) && error.data.errors?.length > 0;
usePoll(
async () => {
const data = await fetchProjectGraph(
projectGraphDataService,
params,
appConfig
);
if (
isRouteErrorResponse(error) &&
error.data.id === 'project-not-found' &&
data.projects.find((p) => p.name === error.data.projectName)
) {
window.location.reload();
}
},
1000,
watch
);
let message: string | JSX.Element;
let stack: string;
if (isRouteErrorResponse(error) && error.data.id === 'project-not-found') {
message = (
<p>
Project <code>{error.data.projectName}</code> not found.
</p>
);
} else {
message = 'Disconnected from graph server. ';
if (environment === 'nx-console') {
message += 'Please refresh the page.';
} else {
message += 'Please rerun your command and refresh the page.';
}
stack = error.toString();
}
return (
<div className="flex h-screen w-full flex-col items-center">
{environment !== 'nx-console' && <ProjectDetailsHeader />}
<ErrorPage
message={message}
stack={stack}
errors={hasErrorData ? error.data.errors : undefined}
/>
</div>
);
}
@@ -0,0 +1,49 @@
// nx-ignore-next-line
import { ErrorRenderer } from '@nx/graph-ui-common';
// nx-ignore-next-line
import { GraphError } from 'nx/src/command-line/graph/graph';
import type { JSX } from 'react';
export type ErrorPageProps = {
message: string | JSX.Element;
stack?: string;
errors: GraphError[];
};
export function ErrorPage({ message, stack, errors }: ErrorPageProps) {
return (
<div className="mx-auto mb-8 w-full max-w-6xl flex-grow px-8">
<h1 className="mb-4 text-4xl dark:text-slate-100">Error</h1>
<div>
<ErrorWithStack message={message} stack={stack} />
</div>
{errors && (
<div>
<p className="text-md mb-4 dark:text-slate-200">
Nx encountered the following issues while processing the project
graph:{' '}
</p>
<div>
<ErrorRenderer errors={errors} />
</div>
</div>
)}
</div>
);
}
function ErrorWithStack({
message,
stack,
}: {
message: string | JSX.Element;
stack?: string;
}) {
return (
<div>
<p className="mb-4 text-lg dark:text-slate-100">{message}</p>
{stack && <p className="text-sm">Error message: {stack}</p>}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
// nx-ignore-next-line
import type { FileDataDependency } from 'nx/src/config/project-graph';
export function createTaskName(
project: string,
target: string,
configuration?: string
) {
if (configuration) {
return `task-${project}:${target}:${configuration}`;
} else {
return `task-${project}:${target}`;
}
}
export function extractDependencyTarget(dependency: FileDataDependency) {
if (typeof dependency === 'string') return dependency;
if (dependency.length === 2) return dependency[0];
return dependency[1];
}
View File
@@ -0,0 +1,28 @@
window.exclude = [];
window.watch = false;
window.environment = 'dev';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: true,
showExperimentalFeatures: true,
workspaces: [
{
id: 'e2e',
label: 'e2e',
projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
taskInputsUrl: 'assets/task-inputs/e2e.json',
sourceMapsUrl: 'assets/source-maps/e2e.json',
},
{
id: 'affected',
label: 'affected',
projectGraphUrl: 'assets/project-graphs/affected.json',
taskGraphUrl: 'assets/task-graphs/affected.json',
taskInputsUrl: 'assets/task-inputs/affected.json',
sourceMapsUrl: 'assets/source-maps/e2e-affected.json',
},
],
defaultWorkspaceId: 'e2e',
};
@@ -0,0 +1,18 @@
window.exclude = [];
window.watch = false;
window.environment = 'nx-console';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: false,
showExperimentalFeatures: false,
workspaces: [
{
id: 'local',
label: 'local',
projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
},
],
defaultWorkspaceId: 'local',
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
window.exclude = [];
window.watch = false;
window.environment = 'release';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: false,
showExperimentalFeatures: false,
workspaces: [
{
id: 'local',
label: 'local',
projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
taskInputsUrl: 'assets/task-inputs/e2e.json',
sourceMapsUrl: 'assets/source-maps/e2e.json',
},
],
defaultWorkspaceId: 'local',
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,450 @@
{
"taskGraph": {
"tasks": {
"products-product-detail-page:lint": {
"id": "products-product-detail-page:lint",
"target": {
"project": "products-product-detail-page",
"target": "lint"
},
"projectRoot": "libs/products/product-detail-page",
"overrides": {}
},
"products-product-detail-page:test": {
"id": "products-product-detail-page:test",
"target": {
"project": "products-product-detail-page",
"target": "test"
},
"projectRoot": "libs/products/product-detail-page",
"overrides": {}
},
"shared-product-state:lint": {
"id": "shared-product-state:lint",
"target": {
"project": "shared-product-state",
"target": "lint"
},
"projectRoot": "libs/shared/product/state",
"overrides": {}
},
"shared-product-state:test": {
"id": "shared-product-state:test",
"target": {
"project": "shared-product-state",
"target": "test"
},
"projectRoot": "libs/shared/product/state",
"overrides": {}
},
"shared-product-types:lint": {
"id": "shared-product-types:lint",
"target": {
"project": "shared-product-types",
"target": "lint"
},
"projectRoot": "libs/shared/product/types",
"overrides": {}
},
"shared-product-data:lint": {
"id": "shared-product-data:lint",
"target": {
"project": "shared-product-data",
"target": "lint"
},
"projectRoot": "libs/shared/product/data",
"overrides": {}
},
"products-home-page:lint": {
"id": "products-home-page:lint",
"target": {
"project": "products-home-page",
"target": "lint"
},
"projectRoot": "libs/products/home-page",
"overrides": {}
},
"products-home-page:test": {
"id": "products-home-page:test",
"target": {
"project": "products-home-page",
"target": "test"
},
"projectRoot": "libs/products/home-page",
"overrides": {}
},
"shared-cart-state:lint": {
"id": "shared-cart-state:lint",
"target": {
"project": "shared-cart-state",
"target": "lint"
},
"projectRoot": "libs/shared/cart/state",
"overrides": {}
},
"shared-cart-state:test": {
"id": "shared-cart-state:test",
"target": {
"project": "shared-cart-state",
"target": "test"
},
"projectRoot": "libs/shared/cart/state",
"overrides": {}
},
"shared-product-ui:lint": {
"id": "shared-product-ui:lint",
"target": {
"project": "shared-product-ui",
"target": "lint"
},
"projectRoot": "libs/shared/product/ui",
"overrides": {}
},
"shared-product-ui:test": {
"id": "shared-product-ui:test",
"target": {
"project": "shared-product-ui",
"target": "test"
},
"projectRoot": "libs/shared/product/ui",
"overrides": {}
},
"shared-e2e-utils:lint": {
"id": "shared-e2e-utils:lint",
"target": {
"project": "shared-e2e-utils",
"target": "lint"
},
"projectRoot": "libs/shared/e2e-utils",
"overrides": {}
},
"cart-cart-page:lint": {
"id": "cart-cart-page:lint",
"target": {
"project": "cart-cart-page",
"target": "lint"
},
"projectRoot": "libs/cart/cart-page",
"overrides": {}
},
"cart-cart-page:test": {
"id": "cart-cart-page:test",
"target": {
"project": "cart-cart-page",
"target": "test"
},
"projectRoot": "libs/cart/cart-page",
"overrides": {}
},
"shared-header:lint": {
"id": "shared-header:lint",
"target": {
"project": "shared-header",
"target": "lint"
},
"projectRoot": "libs/shared/header",
"overrides": {}
},
"shared-header:test": {
"id": "shared-header:test",
"target": {
"project": "shared-header",
"target": "test"
},
"projectRoot": "libs/shared/header",
"overrides": {}
},
"shared-jsxify:lint": {
"id": "shared-jsxify:lint",
"target": {
"project": "shared-jsxify",
"target": "lint"
},
"projectRoot": "libs/shared/jsxify",
"overrides": {}
},
"products-e2e:e2e": {
"id": "products-e2e:e2e",
"target": {
"project": "products-e2e",
"target": "e2e"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"products-e2e:e2e:production": {
"id": "products-e2e:e2e:production",
"target": {
"project": "products-e2e",
"target": "e2e",
"configuration": "production"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"products-e2e:lint": {
"id": "products-e2e:lint",
"target": {
"project": "products-e2e",
"target": "lint"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"cart-e2e:e2e": {
"id": "cart-e2e:e2e",
"target": {
"project": "cart-e2e",
"target": "e2e"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"cart-e2e:e2e:production": {
"id": "cart-e2e:e2e:production",
"target": {
"project": "cart-e2e",
"target": "e2e",
"configuration": "production"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"cart-e2e:lint": {
"id": "cart-e2e:lint",
"target": {
"project": "cart-e2e",
"target": "lint"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"products:build": {
"id": "products:build",
"target": {
"project": "products",
"target": "build"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:build:production": {
"id": "products:build:production",
"target": {
"project": "products",
"target": "build",
"configuration": "production"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:serve": {
"id": "products:serve",
"target": {
"project": "products",
"target": "serve"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:serve:production": {
"id": "products:serve:production",
"target": {
"project": "products",
"target": "serve",
"configuration": "production"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:extract-i18n": {
"id": "products:extract-i18n",
"target": {
"project": "products",
"target": "extract-i18n"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:lint": {
"id": "products:lint",
"target": {
"project": "products",
"target": "lint"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:test": {
"id": "products:test",
"target": {
"project": "products",
"target": "test"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:deploy": {
"id": "products:deploy",
"target": {
"project": "products",
"target": "deploy"
},
"projectRoot": "apps/products",
"overrides": {}
},
"cart:build:production": {
"id": "cart:build:production",
"target": {
"project": "cart",
"target": "build",
"configuration": "production"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:build:development": {
"id": "cart:build:development",
"target": {
"project": "cart",
"target": "build",
"configuration": "development"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:serve:development": {
"id": "cart:serve:development",
"target": {
"project": "cart",
"target": "serve",
"configuration": "development"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:serve:production": {
"id": "cart:serve:production",
"target": {
"project": "cart",
"target": "serve",
"configuration": "production"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:lint": {
"id": "cart:lint",
"target": {
"project": "cart",
"target": "lint"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:test": {
"id": "cart:test",
"target": {
"project": "cart",
"target": "test"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:deploy": {
"id": "cart:deploy",
"target": {
"project": "cart",
"target": "deploy"
},
"projectRoot": "apps/cart",
"overrides": {}
}
},
"dependencies": {
"products-product-detail-page:lint": [],
"products-product-detail-page:test": [],
"shared-product-state:lint": [],
"shared-product-state:test": [],
"shared-product-types:lint": [],
"shared-product-data:lint": [],
"products-home-page:lint": [],
"products-home-page:test": [],
"shared-cart-state:lint": [],
"shared-cart-state:test": [],
"shared-product-ui:lint": [],
"shared-product-ui:test": [],
"shared-e2e-utils:lint": [],
"cart-cart-page:lint": [],
"cart-cart-page:test": [],
"shared-header:lint": [],
"shared-header:test": [],
"shared-jsxify:lint": [],
"products-e2e:e2e": [],
"products-e2e:e2e:production": [],
"products-e2e:lint": [],
"cart-e2e:e2e": [],
"cart-e2e:e2e:production": [],
"cart-e2e:lint": [],
"products:build": [],
"products:build:production": [],
"products:serve": [],
"products:serve:production": [],
"products:extract-i18n": [],
"products:lint": [],
"products:test": [],
"products:deploy": [],
"cart:build:production": [],
"cart:build:development": [],
"cart:serve:development": [],
"cart:serve:production": [],
"cart:lint": [],
"cart:test": [],
"cart:deploy": []
},
"continuousDependencies": {},
"roots": [
"products-product-detail-page:lint",
"products-product-detail-page:test",
"shared-product-state:lint",
"shared-product-state:test",
"shared-product-types:lint",
"shared-product-data:lint",
"products-home-page:lint",
"products-home-page:test",
"shared-cart-state:lint",
"shared-cart-state:test",
"shared-product-ui:lint",
"shared-product-ui:test",
"shared-e2e-utils:lint",
"cart-cart-page:lint",
"cart-cart-page:test",
"shared-header:lint",
"shared-header:test",
"shared-jsxify:lint",
"products-e2e:e2e",
"products-e2e:e2e:production",
"products-e2e:lint",
"cart-e2e:e2e",
"cart-e2e:e2e:production",
"cart-e2e:lint",
"products:build",
"products:build:production",
"products:serve",
"products:serve:production",
"products:extract-i18n",
"products:lint",
"products:test",
"products:deploy",
"cart:build:production",
"cart:build:development",
"cart:serve:development",
"cart:serve:production",
"cart:lint",
"cart:test",
"cart:deploy"
]
},
"plans": {},
"error": null
}
@@ -0,0 +1,450 @@
{
"taskGraph": {
"tasks": {
"products-product-detail-page:lint": {
"id": "products-product-detail-page:lint",
"target": {
"project": "products-product-detail-page",
"target": "lint"
},
"projectRoot": "libs/products/product-detail-page",
"overrides": {}
},
"products-product-detail-page:test": {
"id": "products-product-detail-page:test",
"target": {
"project": "products-product-detail-page",
"target": "test"
},
"projectRoot": "libs/products/product-detail-page",
"overrides": {}
},
"shared-product-state:lint": {
"id": "shared-product-state:lint",
"target": {
"project": "shared-product-state",
"target": "lint"
},
"projectRoot": "libs/shared/product/state",
"overrides": {}
},
"shared-product-state:test": {
"id": "shared-product-state:test",
"target": {
"project": "shared-product-state",
"target": "test"
},
"projectRoot": "libs/shared/product/state",
"overrides": {}
},
"shared-product-types:lint": {
"id": "shared-product-types:lint",
"target": {
"project": "shared-product-types",
"target": "lint"
},
"projectRoot": "libs/shared/product/types",
"overrides": {}
},
"shared-product-data:lint": {
"id": "shared-product-data:lint",
"target": {
"project": "shared-product-data",
"target": "lint"
},
"projectRoot": "libs/shared/product/data",
"overrides": {}
},
"products-home-page:lint": {
"id": "products-home-page:lint",
"target": {
"project": "products-home-page",
"target": "lint"
},
"projectRoot": "libs/products/home-page",
"overrides": {}
},
"products-home-page:test": {
"id": "products-home-page:test",
"target": {
"project": "products-home-page",
"target": "test"
},
"projectRoot": "libs/products/home-page",
"overrides": {}
},
"shared-cart-state:lint": {
"id": "shared-cart-state:lint",
"target": {
"project": "shared-cart-state",
"target": "lint"
},
"projectRoot": "libs/shared/cart/state",
"overrides": {}
},
"shared-cart-state:test": {
"id": "shared-cart-state:test",
"target": {
"project": "shared-cart-state",
"target": "test"
},
"projectRoot": "libs/shared/cart/state",
"overrides": {}
},
"shared-product-ui:lint": {
"id": "shared-product-ui:lint",
"target": {
"project": "shared-product-ui",
"target": "lint"
},
"projectRoot": "libs/shared/product/ui",
"overrides": {}
},
"shared-product-ui:test": {
"id": "shared-product-ui:test",
"target": {
"project": "shared-product-ui",
"target": "test"
},
"projectRoot": "libs/shared/product/ui",
"overrides": {}
},
"shared-e2e-utils:lint": {
"id": "shared-e2e-utils:lint",
"target": {
"project": "shared-e2e-utils",
"target": "lint"
},
"projectRoot": "libs/shared/e2e-utils",
"overrides": {}
},
"cart-cart-page:lint": {
"id": "cart-cart-page:lint",
"target": {
"project": "cart-cart-page",
"target": "lint"
},
"projectRoot": "libs/cart/cart-page",
"overrides": {}
},
"cart-cart-page:test": {
"id": "cart-cart-page:test",
"target": {
"project": "cart-cart-page",
"target": "test"
},
"projectRoot": "libs/cart/cart-page",
"overrides": {}
},
"shared-header:lint": {
"id": "shared-header:lint",
"target": {
"project": "shared-header",
"target": "lint"
},
"projectRoot": "libs/shared/header",
"overrides": {}
},
"shared-header:test": {
"id": "shared-header:test",
"target": {
"project": "shared-header",
"target": "test"
},
"projectRoot": "libs/shared/header",
"overrides": {}
},
"shared-jsxify:lint": {
"id": "shared-jsxify:lint",
"target": {
"project": "shared-jsxify",
"target": "lint"
},
"projectRoot": "libs/shared/jsxify",
"overrides": {}
},
"products-e2e:e2e": {
"id": "products-e2e:e2e",
"target": {
"project": "products-e2e",
"target": "e2e"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"products-e2e:e2e:production": {
"id": "products-e2e:e2e:production",
"target": {
"project": "products-e2e",
"target": "e2e",
"configuration": "production"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"products-e2e:lint": {
"id": "products-e2e:lint",
"target": {
"project": "products-e2e",
"target": "lint"
},
"projectRoot": "apps/products-e2e",
"overrides": {}
},
"cart-e2e:e2e": {
"id": "cart-e2e:e2e",
"target": {
"project": "cart-e2e",
"target": "e2e"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"cart-e2e:e2e:production": {
"id": "cart-e2e:e2e:production",
"target": {
"project": "cart-e2e",
"target": "e2e",
"configuration": "production"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"cart-e2e:lint": {
"id": "cart-e2e:lint",
"target": {
"project": "cart-e2e",
"target": "lint"
},
"projectRoot": "apps/cart-e2e",
"overrides": {}
},
"products:build": {
"id": "products:build",
"target": {
"project": "products",
"target": "build"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:build:production": {
"id": "products:build:production",
"target": {
"project": "products",
"target": "build",
"configuration": "production"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:serve": {
"id": "products:serve",
"target": {
"project": "products",
"target": "serve"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:serve:production": {
"id": "products:serve:production",
"target": {
"project": "products",
"target": "serve",
"configuration": "production"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:extract-i18n": {
"id": "products:extract-i18n",
"target": {
"project": "products",
"target": "extract-i18n"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:lint": {
"id": "products:lint",
"target": {
"project": "products",
"target": "lint"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:test": {
"id": "products:test",
"target": {
"project": "products",
"target": "test"
},
"projectRoot": "apps/products",
"overrides": {}
},
"products:deploy": {
"id": "products:deploy",
"target": {
"project": "products",
"target": "deploy"
},
"projectRoot": "apps/products",
"overrides": {}
},
"cart:build:production": {
"id": "cart:build:production",
"target": {
"project": "cart",
"target": "build",
"configuration": "production"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:build:development": {
"id": "cart:build:development",
"target": {
"project": "cart",
"target": "build",
"configuration": "development"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:serve:development": {
"id": "cart:serve:development",
"target": {
"project": "cart",
"target": "serve",
"configuration": "development"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:serve:production": {
"id": "cart:serve:production",
"target": {
"project": "cart",
"target": "serve",
"configuration": "production"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:lint": {
"id": "cart:lint",
"target": {
"project": "cart",
"target": "lint"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:test": {
"id": "cart:test",
"target": {
"project": "cart",
"target": "test"
},
"projectRoot": "apps/cart",
"overrides": {}
},
"cart:deploy": {
"id": "cart:deploy",
"target": {
"project": "cart",
"target": "deploy"
},
"projectRoot": "apps/cart",
"overrides": {}
}
},
"dependencies": {
"products-product-detail-page:lint": [],
"products-product-detail-page:test": [],
"shared-product-state:lint": [],
"shared-product-state:test": [],
"shared-product-types:lint": [],
"shared-product-data:lint": [],
"products-home-page:lint": [],
"products-home-page:test": [],
"shared-cart-state:lint": [],
"shared-cart-state:test": [],
"shared-product-ui:lint": [],
"shared-product-ui:test": [],
"shared-e2e-utils:lint": [],
"cart-cart-page:lint": [],
"cart-cart-page:test": [],
"shared-header:lint": [],
"shared-header:test": [],
"shared-jsxify:lint": [],
"products-e2e:e2e": [],
"products-e2e:e2e:production": [],
"products-e2e:lint": [],
"cart-e2e:e2e": [],
"cart-e2e:e2e:production": [],
"cart-e2e:lint": [],
"products:build": [],
"products:build:production": [],
"products:serve": [],
"products:serve:production": [],
"products:extract-i18n": [],
"products:lint": [],
"products:test": [],
"products:deploy": [],
"cart:build:production": [],
"cart:build:development": [],
"cart:serve:development": [],
"cart:serve:production": [],
"cart:lint": [],
"cart:test": [],
"cart:deploy": []
},
"continuousDependencies": {},
"roots": [
"products-product-detail-page:lint",
"products-product-detail-page:test",
"shared-product-state:lint",
"shared-product-state:test",
"shared-product-types:lint",
"shared-product-data:lint",
"products-home-page:lint",
"products-home-page:test",
"shared-cart-state:lint",
"shared-cart-state:test",
"shared-product-ui:lint",
"shared-product-ui:test",
"shared-e2e-utils:lint",
"cart-cart-page:lint",
"cart-cart-page:test",
"shared-header:lint",
"shared-header:test",
"shared-jsxify:lint",
"products-e2e:e2e",
"products-e2e:e2e:production",
"products-e2e:lint",
"cart-e2e:e2e",
"cart-e2e:e2e:production",
"cart-e2e:lint",
"products:build",
"products:build:production",
"products:serve",
"products:serve:production",
"products:extract-i18n",
"products:lint",
"products:test",
"products:deploy",
"cart:build:production",
"cart:build:development",
"cart:serve:development",
"cart:serve:production",
"cart:lint",
"cart:test",
"cart:deploy"
]
},
"plans": {},
"error": null
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
window.exclude = [];
window.watch = true;
window.environment = 'watch';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: false,
showExperimentalFeatures: true,
workspaces: [
{
id: 'local',
label: 'local',
projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
},
],
defaultWorkspaceId: 'local',
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+63
View File
@@ -0,0 +1,63 @@
// nx-ignore-next-line
import type {
ExpandedTaskInputsReponse,
ProjectGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
import type { AppConfig, ExternalApi } from '@nx/graph/shared';
import {
ProjectDetailsEvents,
projectDetailsMachine,
ProjectDetailsState,
} from './app/console/project-details/project-details.machine';
import { Interpreter } from 'xstate';
export declare global {
interface Window {
exclude: string[];
watch: boolean;
localMode: 'serve' | 'build';
projectGraphResponse?: ProjectGraphClientResponse;
taskGraphResponse?: TaskGraphClientResponse;
expandedTaskInputsResponse?: ExpandedTaskInputsReponse;
sourceMapsResponse?: Record<string, Record<string, string[]>>;
environment: 'dev' | 'watch' | 'release' | 'nx-console';
appConfig: AppConfig;
useXstateInspect: boolean;
externalApi?: ExternalApi;
// using bundled graph components directly from outside the graph app
__NX_RENDER_GRAPH__?: boolean;
renderPDV?: (
data: any
) => Interpreter<ProjectDetailsState, any, ProjectDetailsEvents>;
renderError?: (data: any) => void;
renderMigrate?: (
data: any
) => Interpreter<MigrateState, any, MigrateEvents>;
}
}
declare module 'cytoscape' {
interface Core {
anywherePanning: Function;
}
interface ElementDefinition {
pannable?: boolean;
}
interface NodeSingular {
popperRef: Function;
pannable: () => boolean;
}
interface EdgeSingular {
popperRef: Function;
}
namespace Css {
interface EdgeLine {
'edge-text-rotation'?: string;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nx Workspace Project Graph</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script id="environment" src="environment.js"></script>
<!-- Theming -->
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (
localStorage.getItem('nx-dep-graph-theme') === 'dark' ||
(localStorage.getItem('nx-dep-graph-theme') === null &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body
class="h-screen w-screen bg-white text-slate-500 dark:bg-slate-900 dark:text-slate-400"
>
<div class="flex h-full w-full p-0" id="app"></div>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
/* eslint-disable import/first */
// debug must be first import
if (process.env.NODE_ENV === 'development') {
require('preact/debug');
}
import { projectDetailsMachine } from './app/console-project-details/project-details.machine';
// nx-ignore-next-line
import type { ProjectGraphProjectNode } from '@nx/devkit';
// nx-ignore-next-line
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';
import { StrictMode } from 'react';
import { inspect } from '@xstate/inspect';
import { App } from './app/app';
import { ExternalApiImpl } from './app/external-api-impl';
import { render } from 'preact';
import { ErrorPage } from './app/ui-components/error-page';
import { ProjectDetailsApp } from './app/console-project-details/project-details.app';
import { interpret } from 'xstate';
import { MigrateApp } from './app/console-migrate/migrate.app';
import { migrateMachine } from './app/console-migrate/migrate.machine';
import { ThemeProvider } from '@nx/graph-internal-ui-render-config';
if (window.__NX_RENDER_GRAPH__ === false) {
window.externalApi = new ExternalApiImpl();
window.renderPDV = (data: {
project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>;
connectedToCloud: boolean;
errors?: GraphError[];
}) => {
const service = interpret(projectDetailsMachine).start();
service.send({
type: 'loadData',
...data,
});
render(
<StrictMode>
<ThemeProvider>
<ProjectDetailsApp service={service} />
</ThemeProvider>
</StrictMode>,
document.getElementById('app')
);
return service;
};
window.renderError = (data: {
message: string;
stack?: string;
errors: GraphError[];
}) => {
render(
<StrictMode>
<ThemeProvider>
<ErrorPage {...data} />
</ThemeProvider>
</StrictMode>,
document.getElementById('app')
);
};
window.renderMigrate = (data: {
migrations: GeneratedMigrationDetails[];
'nx-console': MigrationsJsonMetadata;
}) => {
const service = interpret(migrateMachine).start();
service.send({
type: 'loadData',
...data,
});
render(
<StrictMode>
<ThemeProvider>
<MigrateApp service={service} />
</ThemeProvider>
</StrictMode>,
document.getElementById('app')
);
return service;
};
} else {
if (window.useXstateInspect === true) {
inspect({
url: 'https://stately.ai/viz?inspect',
iframe: false, // open in new window
});
}
window.externalApi = new ExternalApiImpl();
const container = document.getElementById('app');
if (!window.appConfig) {
render(
<p>
No environment could be found. Please run{' '}
<pre>npx nx run graph-client:generate-dev-environment-js</pre>.
</p>,
container
);
} else {
render(
<StrictMode>
<App />
</StrictMode>,
container
);
}
}
+97
View File
@@ -0,0 +1,97 @@
/*! purgecss start ignore */
@import './syntax-highlight.css';
/*! purgecss end ignore */
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms' {
strategy: class;
}
@custom-variant dark (&:where(.dark, .dark *));
@source '../src';
@source '../../ui-code-block/src';
@source '../../ui-common/src';
@source '../../ui-icons/src';
@source '../../ui-project-details/src';
@source '../../ui-render-config/src';
@source '../../shared/src';
@source '../../migrate/src';
@source '../../../node_modules/@nx/graph';
@source not '../../**/*.{spec,test,stories}.*';
@source not '../../**/eslint.config.*';
/* v3 compat: default border color was gray-200, v4 is currentColor. */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/* Strip default quote characters around inline code and blockquote. */
.prose :where(code):not(:where([class~='not-prose'] *))::before,
.prose :where(code):not(:where([class~='not-prose'] *))::after,
.prose
:where(blockquote p:first-of-type):not(
:where([class~='not-prose'] *)
)::before,
.prose
:where(blockquote p:last-of-type):not(:where([class~='not-prose'] *))::after {
content: '';
}
/** Scrollbars **/
.dark {
color-scheme: dark;
}
/**
* Tooltips
*/
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
background-color: hsla(214, 61%, 11%, 1);
border-radius: 0.375rem;
color: hsla(0, 0%, 95%, 1);
content: attr(data-tooltip);
left: 0;
opacity: 0;
padding: 0.25rem 0.5rem;
position: absolute;
text-align: center;
text-transform: capitalize;
top: 120%;
transition: opacity 150ms ease-out;
visibility: hidden;
z-index: 1;
width: max-content;
}
[data-tooltip][data-tooltip-align-right]:before {
left: auto;
right: 0;
}
[data-tooltip]:hover:before {
opacity: 1;
visibility: visible;
}
/* Dark mode */
html.dark .adaptive-icon {
/* fill: white; */
filter: invert(1);
}
.adaptive-icon {
fill: black;
}
+79
View File
@@ -0,0 +1,79 @@
html {
--interactive-muted: #94a3b8;
}
.dark {
--interactive-muted: #64748b;
}
.hljs .hljs-comment,
.hljs .hljs-quote {
color: var(--interactive-muted);
}
.hljs .hljs-addition,
.hljs .hljs-keyword,
.hljs .hljs-selector-tag {
color: #859900;
}
.hljs .hljs-doctag,
.hljs .hljs-literal,
.hljs .hljs-meta .hljs .hljs-meta-string,
.hljs .hljs-number,
.hljs .hljs-regexp,
.hljs .hljs-string {
color: #2aa198;
}
.hljs .hljs-name,
.hljs .hljs-section,
.hljs .hljs-selector-class,
.hljs .hljs-selector-id,
.hljs .hljs-title {
color: #268bd2;
}
.hljs .hljs-attr,
.hljs .hljs-attribute,
.hljs .hljs-class .hljs .hljs-title,
.hljs .hljs-template-variable,
.hljs .hljs-type,
.hljs .hljs-variable {
color: #b58900;
}
.hljs .hljs-bullet,
.hljs .hljs-link,
.hljs .hljs-meta,
.hljs .hljs-meta .hljs .hljs-keyword,
.hljs .hljs-selector-attr,
.hljs .hljs-selector-pseudo,
.hljs .hljs-subst,
.hljs .hljs-symbol {
color: #cb4b16;
}
.hljs .hljs-built_in,
.hljs .hljs-deletion {
color: #dc322f;
}
.hljs .hljs-formula {
background: #073642;
}
.hljs .hljs-emphasis {
font-style: italic;
}
.hljs .hljs-strong {
font-weight: 700;
}
.linenumber {
border-color: rgb(59 130 246);
background: #e5e7eb;
}
html.dark .linenumber {
background: #e5e7eb33;
}
+55
View File
@@ -0,0 +1,55 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "out-tsc",
"tsBuildInfoFile": "out-tsc/tsconfig.app.tsbuildinfo",
"declaration": true,
"composite": true,
"types": ["node"],
"lib": ["DOM", "es2022"]
},
"files": [
"../../node_modules/@nx/react/typings/cssmodule.d.ts",
"../../node_modules/@nx/react/typings/image.d.ts"
],
"exclude": [
"out-tsc",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"jest.config.ts",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"],
"references": [
{
"path": "../ui-code-block/tsconfig.lib.json"
},
{
"path": "../ui-common/tsconfig.lib.json"
},
{
"path": "../shared/tsconfig.lib.json"
},
{
"path": "../migrate/tsconfig.lib.json"
},
{
"path": "../ui-project-details/tsconfig.lib.json"
},
{
"path": "../project-details/tsconfig.lib.json"
},
{
"path": "../ui-render-config/tsconfig.lib.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "out-tsc/spec",
"customConditions": ["@nx/nx-source"],
"types": ["jest", "node"],
"lib": ["DOM"]
},
"exclude": ["out-tsc"],
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts",
"jest.config.ts"
],
"files": [
"../../node_modules/@nx/react/typings/cssmodule.d.ts",
"../../node_modules/@nx/react/typings/image.d.ts"
]
}
+158
View File
@@ -0,0 +1,158 @@
const { join } = require('node:path');
// nx-ignore-next-line
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
// nx-ignore-next-line
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
module.exports = {
output: {
path: join(__dirname, 'dist'),
},
devServer: {
port: getPort(process.env.NX_TASK_TARGET_CONFIGURATION),
historyApiFallback: {
disableDotRule: true,
},
},
resolve: {
alias: {
react: 'preact/compat',
'react-dom/test-utils': 'preact/test-utils',
'react-dom': 'preact/compat', // Must be below test-utils
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
plugins: [
new NxAppWebpackPlugin({
index: './src/index.html',
compiler: 'babel',
main: './src/main.tsx',
tsConfig: './tsconfig.app.json',
styles: ['./src/styles.css'],
scripts: [],
assets: getAssets(process.env.NX_TASK_TARGET_CONFIGURATION),
webpackConfig: './webpack.config.js',
outputHashing: 'none',
deleteOutputPath: false,
}),
new NxReactWebpackPlugin({
svgr: false,
}),
],
};
function getPort(nxConfiguration) {
switch (nxConfiguration) {
case 'nx-console':
return 4202;
case 'release':
return 4203;
case 'watch':
return 4204;
case 'release-static':
return 4205;
case 'dev-e2e':
return 4206;
default: // dev
return 4201;
}
}
function getAssets(nxConfiguration) {
switch (nxConfiguration) {
case 'nx-console':
return [
'./src/favicon.ico',
{
input: './src/assets/project-graphs',
output: '/assets/project-graphs',
glob: 'e2e.json',
},
{
input: './src/assets/nx-console',
output: '/',
glob: 'environment.js',
},
];
case 'release':
return [
'./src/favicon.ico',
{
input: './src/assets/project-graphs',
output: '/assets/project-graphs',
glob: 'e2e.json',
},
{
input: './src/assets/task-graphs',
output: '/assets/task-graphs',
glob: 'e2e.json',
},
{
input: './src/assets/source-maps',
output: '/assets/source-maps',
glob: 'e2e.json',
},
{
input: './src/assets/release',
output: '/',
glob: 'environment.js',
},
];
case 'watch':
return [
'./src/favicon.ico',
{
input: './src/assets/watch',
output: '/',
glob: 'environment.js',
},
];
case 'release-static':
return [
'./src/favicon.ico',
{
input: './src/assets/project-graphs',
output: '/assets/project-graphs',
glob: 'e2e.json',
},
{
input: './src/assets/task-graphs',
output: '/assets/task-graphs',
glob: 'e2e.json',
},
{
input: './src/assets/release-static',
output: '/',
glob: 'environment.js',
},
];
case 'dev-e2e':
return [
'./src/favicon.ico',
'./src/assets/project-graphs/',
'./src/assets/task-graphs/',
'./src/assets/task-inputs/',
'./src/assets/source-maps/',
{
input: './src/assets/dev-e2e',
output: '/',
glob: 'environment.js',
},
];
default: // dev
return [
'./src/favicon.ico',
'./src/assets/project-graphs/',
'./src/assets/task-graphs/',
'./src/assets/generated-project-graphs/',
'./src/assets/generated-task-graphs/',
'./src/assets/generated-task-inputs/',
'./src/assets/generated-source-maps/',
{
input: './src/assets/dev',
output: '/',
glob: 'environment.js',
},
];
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
+30
View File
@@ -0,0 +1,30 @@
import type { StorybookConfig } from '@storybook/react-vite';
import { mergeConfig } from 'vite';
const config: StorybookConfig = {
stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: ['@storybook/addon-docs'],
framework: {
name: '@storybook/react-vite',
options: {},
},
viteFinal: async (config) => {
const {
nxViteTsPaths,
// nx-ignore-next-line
} = await import('@nx/vite/plugins/nx-tsconfig-paths.plugin');
return mergeConfig(config, { plugins: [nxViteTsPaths()] });
},
typescript: {
reactDocgen: 'react-docgen-typescript',
},
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
+2
View File
@@ -0,0 +1,2 @@
import './tailwind.css';
export const tags = ['autodocs'];
+53
View File
@@ -0,0 +1,53 @@
@import 'tailwindcss';
@source '../src';
@source '../../ui-code-block/src';
@source '../../ui-common/src';
@source '../../ui-icons/src';
@source '../../ui-project-details/src';
@source '../../ui-render-config/src';
@source '../../shared/src';
@source not '../../**/*.spec.*';
@source not '../../**/eslint.config.*';
/* v3 compat: default border color was gray-200, v4 is currentColor. */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@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
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).
+11
View File
@@ -0,0 +1,11 @@
import { baseConfig, reactHooksV7Off } from '../../eslint.config.mjs';
import nx from '@nx/eslint-plugin';
export default [
...baseConfig,
...nx.configs['flat/react'],
...reactHooksV7Off,
{
ignores: ['storybook-static'],
},
];
+19
View File
@@ -0,0 +1,19 @@
// nx-ignore-next-line
const nxPreset = require('@nx/jest/preset').default;
module.exports = {
...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: [
...(nxPreset.modulePathIgnorePatterns ?? []),
'/graph/client/src/app/machines/match-media-mock.spec.ts',
],
};
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@nx/graph-migrate",
"version": "0.0.1",
"type": "commonjs",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"devDependencies": {
"jest": "catalog:jest"
},
"dependencies": {
"@nx/graph-ui-common": "workspace:",
"@nx/graph-internal-ui-project-details": "workspace:",
"tailwind-merge": "catalog:tailwind"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};
+18
View File
@@ -0,0 +1,18 @@
{
"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": {
"typecheck": {
"dependsOn": ["^typecheck", "nx:build-native"],
"inputs": [
"...",
"{workspaceRoot}/packages/devkit/tsconfig.lib.json",
"{workspaceRoot}/packages/nx/tsconfig.lib.json"
]
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/migrate';
@@ -0,0 +1,104 @@
// nx-ignore-next-line
import { FileChange } from 'nx/src/devkit-exports';
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import { useSelector } from '@xstate/react';
import type { MigrationsJsonMetadata } from '../migration-shape';
import {
currentMigrationHasChanges,
getCurrentMigrationType,
} 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;
onUndoMigration: (migration: MigrationDetailsWithId) => void;
onAcknowledgePrompt: (migration: MigrationDetailsWithId) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
) => void;
onViewImplementation: (migration: MigrationDetailsWithId) => void;
onViewDocumentation: (migration: MigrationDetailsWithId) => void;
onViewPrompt: (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 currentMigrationFailed = useSelector(
props.actor,
(state) => getCurrentMigrationType(state.context) === 'failed'
);
const isCurrentMigrationStopped = useSelector(
props.actor,
(state) => getCurrentMigrationType(state.context) === 'stopped'
);
const currentMigrationSuccess = useSelector(
props.actor,
(state) => getCurrentMigrationType(state.context) === 'successful'
);
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
actor={props.actor}
migrations={props.migrations}
nxConsoleMetadata={props.nxConsoleMetadata}
currentMigrationIndex={
currentMigrationIndex >= 0 ? currentMigrationIndex : 0
}
currentMigrationFailed={currentMigrationFailed}
currentMigrationSuccess={currentMigrationSuccess}
currentMigrationHasChanges={currentMigrationChanges}
currentMigrationStopped={isCurrentMigrationStopped}
isDone={isDone}
isInit={isInit}
onRunMigration={props.onRunMigration}
onSkipMigration={props.onSkipMigration}
onUndoMigration={props.onUndoMigration}
onAcknowledgePrompt={props.onAcknowledgePrompt}
onFileClick={props.onFileClick}
onViewImplementation={props.onViewImplementation}
onViewDocumentation={props.onViewDocumentation}
onViewPrompt={props.onViewPrompt}
onReviewMigration={handleReviewMigration}
/>
);
}
@@ -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';
@@ -0,0 +1,483 @@
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
// nx-ignore-next-line
import { FileChange } from 'nx/src/devkit-exports';
import {
ArrowPathIcon,
CheckCircleIcon,
ClockIcon,
CodeBracketIcon,
ExclamationCircleIcon,
ListBulletIcon,
PlayIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import { Pill } from '@nx/graph-internal-ui-project-details';
import {
useState,
forwardRef,
useImperativeHandle,
useEffect,
type ReactNode,
} from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { Interpreter } from 'xstate';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from '../state/automatic/types';
import { useSelector } from '@xstate/react';
import {
currentMigrationHasChanges,
getMigrationType,
isMigrationRunning,
} from '../state/automatic/selectors';
import {
isHybridShape,
isPromptOnlyShape,
type MigrationsJsonMetadata,
} from '../migration-shape';
export interface MigrationCardHandle {
expand: () => void;
collapse: () => void;
toggle: () => void;
}
function convertUrlsToLinks(text: string): ReactNode[] {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
const urls = text.match(urlRegex) || [];
const result: ReactNode[] = [];
for (let i = 0; i < parts.length; i++) {
if (urls[i - 1]) {
result.push(
<a
key={i}
rel="noopener noreferrer"
href={urls[i - 1]}
target="_blank"
className="text-blue-500 hover:underline"
>
{urls[i - 1]}
</a>
);
} else if (parts[i]) {
result.push(parts[i]);
}
}
return result;
}
export const MigrationCard = forwardRef<
MigrationCardHandle,
{
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
migration: MigrationDetailsWithId;
nxConsoleMetadata: MigrationsJsonMetadata;
isSelected?: boolean;
onSelect?: (isSelected: boolean) => void;
onRunMigration?: () => void;
onFileClick: (file: Omit<FileChange, 'content'>) => void;
onViewImplementation: () => void;
onViewDocumentation: () => void;
onViewPrompt: () => void;
isExpanded?: boolean;
}
>(function MigrationCard(
{
actor,
migration,
nxConsoleMetadata,
isSelected,
onSelect,
onRunMigration,
onFileClick,
onViewImplementation,
onViewDocumentation,
onViewPrompt,
isExpanded: isExpandedProp,
},
ref
) {
const [isExpanded, setIsExpanded] = useState(isExpandedProp ?? false);
useImperativeHandle(
ref,
() => ({
expand: () => setIsExpanded(true),
collapse: () => setIsExpanded(false),
toggle: () => setIsExpanded((prev) => !prev),
}),
[]
);
useEffect(() => {
if (isExpandedProp !== undefined) {
setIsExpanded(isExpandedProp);
}
}, [isExpandedProp]);
const migrationResult = nxConsoleMetadata.completedMigrations?.[migration.id];
const filesChanged =
migrationResult?.type === 'successful' ? migrationResult.changedFiles : [];
const nextSteps =
migrationResult?.type === 'successful' ? migrationResult.nextSteps : [];
const isPromptOnly = isPromptOnlyShape(migration);
const isHybrid = isHybridShape(migration);
const isPromptBearing = isPromptOnly || isHybrid;
const isSuccessful = migrationResult?.type === 'successful';
const acknowledgedPrompt =
isSuccessful && !!migrationResult.acknowledgedPrompt;
// For terminal non-success states the Failed/Skipped/Stopped pill in the
// card top-right already conveys the outcome — stay out of the way.
const showPromptStatusRow =
isPromptBearing && (!migrationResult || isSuccessful);
// The prompt-path reminder lives in the next-steps section for every
// prompt-bearing phase where running the prompt is (or was) actionable:
// prompt-only from the start, hybrid only once the generator succeeded.
const showPromptNextStep =
showPromptStatusRow && (isPromptOnly || isSuccessful) && !!migration.prompt;
const isSucceeded = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'successful'
);
const isFailed = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'failed'
);
const isSkipped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'skipped'
);
const isStopped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'stopped'
);
const hasChanges = useSelector(actor, (state) =>
currentMigrationHasChanges(state.context)
);
const isRunning = useSelector(actor, (state) =>
isMigrationRunning(state.context, migration.id)
);
const renderSelectBox = onSelect && isSelected !== undefined;
const isNxMigration =
migration.package.startsWith('@nx') || migration.package.startsWith('nx');
return (
<div
key={migration.id}
className={`gap-2 rounded-md p-2 transition-colors`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{renderSelectBox && (
<div className="h-4 w-4">
<input
checked={isSelected}
onChange={(e) => onSelect((e.target as any).checked)}
id={migration.id}
name={migration.id}
value={migration.id}
type="checkbox"
className={`h-4 w-4 ${
isSucceeded
? 'accent-green-600 dark:accent-green-500'
: isFailed
? 'accent-red-600 dark:accent-red-500'
: 'accent-blue-500 dark:accent-sky-500'
}`}
/>
</div>
)}
<div className={`flex flex-col gap-1`}>
<div
className={`flex items-center gap-2 ${
isNxMigration ? 'cursor-pointer gap-1 hover:underline' : ''
}`}
onClick={() => {
if (isNxMigration) {
onViewDocumentation();
}
}}
>
{/* <div>{migration.name}</div>
{isNxMigration && (
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
)} */}
</div>
<span className="mb-2 text-sm">{migration.description}</span>
<div className="flex items-center gap-2">
{migration.package && (
<Pill
text={`${migration.package}: ${migration.version}`}
color={'grey'}
/>
)}
{isPromptBearing && <AIBadge />}
</div>
{showPromptStatusRow && (
<PromptStatusRow
isHybrid={isHybrid}
isSuccessful={isSuccessful}
acknowledgedPrompt={acknowledgedPrompt}
/>
)}
</div>
</div>
<div className="flex items-center gap-2">
{' '}
{isSucceeded && !hasChanges && (
<Pill text="No changes made" color="green" />
)}
{isSucceeded && hasChanges && (
<div>
<div
className="cursor-pointer"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<Pill
key="changes"
text={`${filesChanged.length} changes`}
color="green"
/>
</div>
</div>
)}
{isFailed && (
<div>
<Pill text="Failed" color="red" />
</div>
)}
{isSkipped && (
<div>
<Pill text="Skipped" color="grey" />
</div>
)}
{isStopped && (
<div>
<Pill text="Stopped" color="yellow" />
</div>
)}
{onRunMigration && !isStopped && !isPromptOnly && (
<span
className={`rounded-md p-1 text-sm ring-1 transition-colors ring-inset ${
isSucceeded
? 'bg-green-50 text-green-700 ring-green-200 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-500 dark:ring-green-900/30 dark:hover:bg-green-900/30'
: isFailed
? 'bg-red-50 text-red-700 ring-red-200 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:ring-red-900/30 dark:hover:bg-red-900/30'
: 'bg-inherit text-slate-600 ring-slate-400/40 hover:bg-slate-200 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-700/60'
}`}
>
{isRunning ? (
<ArrowPathIcon
className="h-6 w-6 animate-spin cursor-not-allowed text-blue-500"
aria-label="Migration in progress"
/>
) : !isSucceeded && !isFailed && !isStopped ? (
<PlayIcon
onClick={onRunMigration}
className="h-6 w-6 !cursor-pointer"
aria-label="Run migration"
/>
) : (
<ArrowPathIcon
onClick={onRunMigration}
className="h-6 w-6 !cursor-pointer"
aria-label="Rerun migration"
/>
)}
</span>
)}
</div>
</div>
{(isSucceeded && nextSteps?.length) || showPromptNextStep ? (
<div className="pt-2">
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60" />
<span className="pb-2 text-sm font-bold">
More Information & Next Steps
</span>
<ul className="list-inside list-disc pl-2">
{nextSteps.map((step, idx) => (
<li key={idx} className="text-sm">
{convertUrlsToLinks(step)}
</li>
))}
{showPromptNextStep && (
<li className="text-sm">
Run the AI prompt at{' '}
<code
className="cursor-pointer text-sky-500 underline-offset-2 hover:underline dark:text-sky-300"
onClick={() => onViewPrompt()}
>
{migration.prompt}
</code>{' '}
to complete this migration.
</li>
)}
</ul>
<p></p>
</div>
) : null}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => onViewImplementation()}
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
<CodeBracketIcon className="h-4 w-4" />
View Source
</button>
{isFailed && (
<button
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<ExclamationCircleIcon className="h-4 w-4" />
{isExpanded ? 'Hide Errors' : 'View Errors'}
</button>
)}
{isSucceeded && hasChanges && (
<button
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<ListBulletIcon className="h-4 w-4" />
{isExpanded ? 'Hide Changes' : 'View Changes'}
</button>
)}
</div>
<AnimatePresence>
{isFailed && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex overflow-hidden pt-2"
>
<pre>{(migrationResult as any)?.error}</pre>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isStopped && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex overflow-hidden pt-2"
>
<pre>{(migrationResult as any)?.error}</pre>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isSucceeded && hasChanges && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60"></div>
<span className="pb-2 text-sm font-bold">File Changes</span>
<ul className="flex flex-col gap-2">
{filesChanged.map((file) => {
return (
<li
className="cursor-pointer text-sm hover:underline"
key={`${migration.id}-${file.path}`}
onClick={() => {
onFileClick(file);
}}
>
<code>{file.path}</code>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
function AIBadge() {
return (
<span
className="inline-flex items-center gap-1 rounded bg-sky-300/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-sky-300 uppercase ring-1 ring-sky-300/40 ring-inset"
aria-label="AI-assisted migration"
>
<SparklesIcon className="h-3 w-3" />
AI
</span>
);
}
function PromptStatusRow({
isHybrid,
isSuccessful,
acknowledgedPrompt,
}: {
isHybrid: boolean;
isSuccessful: boolean;
acknowledgedPrompt: boolean;
}) {
// Prompt-only is "completed" the moment the metadata records success (the
// short-circuit does that); hybrid needs the separate acknowledgment.
const isCompleted = isSuccessful && (!isHybrid || acknowledgedPrompt);
const showGeneratorComplete = isHybrid && isSuccessful && !isCompleted;
return (
<div className="mt-3 flex items-center gap-3 text-xs">
{isCompleted ? (
<span className="inline-flex items-center gap-1 text-green-500">
<CheckCircleIcon className="h-3.5 w-3.5" />
Completed
</span>
) : (
<>
{showGeneratorComplete && (
<>
<span className="inline-flex items-center gap-1 text-green-500">
<CheckCircleIcon className="h-3.5 w-3.5" />
Generator complete
</span>
<span className="text-slate-400/60">·</span>
</>
)}
<span className="inline-flex items-center gap-1 text-sky-300">
<ClockIcon className="h-3.5 w-3.5" />
AI prompt pending
</span>
</>
)}
</div>
);
}
@@ -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-xs transition-colors hover:bg-slate-50 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none 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="rounded-l-md border border-blue-700 bg-blue-500 px-6 py-3 text-sm font-medium whitespace-nowrap text-white shadow-xs 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="flex items-center rounded-r-md border border-l-1 border-blue-700 bg-blue-500 px-2 py-2 text-sm font-medium text-white shadow-xs 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>
);
}
@@ -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>
);
}
@@ -0,0 +1,184 @@
// nx-ignore-next-line
import { FileChange } from 'nx/src/devkit-exports';
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import { PlayIcon } from '@heroicons/react/24/outline';
import { useCallback, useMemo, useState } from 'react';
import { MigrationCard } from './migration-card';
import { MigrationsJsonMetadata } from '../migration-shape';
import type { Interpreter } from 'xstate';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from '../state/automatic/types';
export function MigrationList(props: {
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
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;
onViewPrompt: (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-xs 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-xs 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
actor={props.actor}
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);
}}
onViewPrompt={() => {
props.onViewPrompt(migration);
}}
onFileClick={(file) => {
props.onFileClick(migration, file);
}}
/>
))}
</div>
</>
);
}
@@ -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-xs 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>
);
}
@@ -0,0 +1,732 @@
// nx-ignore-next-line
import { FileChange } from 'nx/src/devkit-exports';
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import {
ChevronUpIcon,
ChevronDownIcon,
ExclamationCircleIcon,
CheckCircleIcon,
ClockIcon,
MinusIcon,
ArrowPathIcon,
XMarkIcon,
CheckIcon,
} 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';
import type { Interpreter } from 'xstate';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from '../state/automatic/types';
import { useSelector } from '@xstate/react';
import {
getMigrationType,
isMigrationRunning,
} from '../state/automatic/selectors';
import {
isHybridShape,
isPromptOnlyShape,
type MigrationsJsonMetadata,
} from '../migration-shape';
export interface MigrationTimelineProps {
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
currentMigrationIndex: number;
currentMigrationFailed?: boolean;
currentMigrationSuccess?: boolean;
currentMigrationHasChanges?: boolean;
currentMigrationStopped?: boolean;
isDone?: boolean;
isInit: boolean;
onRunMigration: (migration: MigrationDetailsWithId) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onUndoMigration: (migration: MigrationDetailsWithId) => void;
onAcknowledgePrompt: (migration: MigrationDetailsWithId) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
) => void;
onViewImplementation: (migration: MigrationDetailsWithId) => void;
onViewDocumentation: (migration: MigrationDetailsWithId) => void;
onViewPrompt: (migration: MigrationDetailsWithId) => void;
onCancel?: () => void;
onReviewMigration: (migrationId: string) => void;
}
export function MigrationTimeline({
actor,
migrations,
nxConsoleMetadata,
currentMigrationIndex,
currentMigrationFailed,
currentMigrationSuccess,
currentMigrationHasChanges,
currentMigrationStopped,
onRunMigration,
onSkipMigration,
onUndoMigration,
onAcknowledgePrompt,
onFileClick,
onViewImplementation,
onViewDocumentation,
onViewPrompt,
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 (including
// prompt-only-pending, where the user needs to confirm they ran the prompt).
const promptOnlyPending =
!!currentMigration &&
isPromptOnlyShape(currentMigration) &&
!currentMigrationSuccess;
// Hybrid post-generator entries also need explicit ack — even when the
// generator produced no diff (otherwise the machine would silently advance
// past the prompt phase).
const currentEntry = currentMigration
? nxConsoleMetadata.completedMigrations?.[currentMigration.id]
: undefined;
const hybridNeedsAck =
!!currentMigration &&
isHybridShape(currentMigration) &&
currentEntry?.type === 'successful' &&
!currentEntry.acknowledgedPrompt;
useEffect(() => {
if (
currentMigrationFailed ||
currentMigrationHasChanges ||
currentMigrationStopped ||
promptOnlyPending ||
hybridNeedsAck
) {
toggleMigrationExpanded(currentMigration.id, true);
} else {
toggleMigrationExpanded(currentMigration.id, false);
}
}, [
currentMigrationHasChanges,
currentMigrationFailed,
currentMigration,
currentMigrationStopped,
promptOnlyPending,
hybridNeedsAck,
]);
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-xs 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 top-0 left-10 w-0.5 -translate-x-1/2 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 -translate-x-1/2 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
actor={actor}
migration={migration}
nxConsoleMetadata={nxConsoleMetadata}
onClick={() => toggleMigrationExpanded(migration.id)}
/>
<div
className={twMerge(
`mt-1 ml-6`,
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 text-base whitespace-nowrap ${
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="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
<ArrowPathIcon
className="h-5 w-5"
aria-hidden="true"
/>{' '}
Rerun
</button>
)}
</div>
)}
</div>
<Collapsible
isOpen={expandedMigrations[migration.id]}
className="mt-2 w-full rounded-md border border-slate-300 p-3"
>
<MigrationCard
actor={actor}
migration={migration}
isExpanded={expandedMigrations[migration.id]}
nxConsoleMetadata={nxConsoleMetadata}
onFileClick={(file) => onFileClick(migration, file)}
onViewImplementation={() =>
onViewImplementation(migration)
}
onViewDocumentation={() =>
onViewDocumentation(migration)
}
onViewPrompt={() => onViewPrompt(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
actor={actor}
migration={migrations[currentMigrationIndex]}
nxConsoleMetadata={nxConsoleMetadata}
isCurrent
needsAttention={
currentMigrationHasChanges &&
!expandedMigrations[currentMigration.id]
}
onClick={() => toggleMigrationExpanded(currentMigration.id)}
/>
<div
className={twMerge(
`mt-1 ml-6`,
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="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
<ArrowPathIcon
className="h-5 w-5"
aria-hidden="true"
/>{' '}
Rerun
</button>
)}
{!currentMigrationSuccess && (
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onSkipMigration(currentMigration);
}}
type="button"
className="flex items-center rounded-md border border-red-500 bg-red-500 px-4 py-2 text-sm font-medium text-white shadow-xs hover:bg-red-600 dark:border-red-700 dark:bg-red-600 hover:dark:bg-red-700"
>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />{' '}
Skip
</button>
)}
{currentMigrationHasChanges && (
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onUndoMigration(currentMigration);
}}
type="button"
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />{' '}
Undo and Skip
</button>
)}
{(currentMigrationHasChanges ||
isPromptOnlyShape(currentMigration) ||
hybridNeedsAck) && (
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onReviewMigration(currentMigration.id);
// Prompt-bearing migrations bundle the AI-prompt
// acknowledgement into this click — one button
// sets the local review flag and (via the backend)
// either records success for prompt-only or sets
// the acknowledgedPrompt flag for hybrid.
if (
isPromptOnlyShape(currentMigration) ||
isHybridShape(currentMigration)
) {
onAcknowledgePrompt(currentMigration);
}
}}
type="button"
className="flex items-center gap-2 rounded-md border border-blue-500 bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-xs hover:bg-blue-600 dark:border-blue-600 dark:bg-blue-600 hover:dark:bg-blue-700"
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />{' '}
{currentMigrationHasChanges
? 'Approve Changes'
: 'Mark as Run'}
</button>
)}
</div>
)}
</div>
<Collapsible
className="mt-2 w-full rounded-md border border-slate-300/60"
isOpen={expandedMigrations[currentMigration.id]}
>
{/* Migration Card */}
<MigrationCard
actor={actor}
ref={currentMigrationRef}
migration={currentMigration}
isExpanded={expandedMigrations[currentMigration.id]}
nxConsoleMetadata={nxConsoleMetadata}
onFileClick={(file) => onFileClick(currentMigration, file)}
onViewImplementation={() =>
onViewImplementation(currentMigration)
}
onViewDocumentation={() =>
onViewDocumentation(currentMigration)
}
onViewPrompt={() => onViewPrompt(currentMigration)}
/>
</Collapsible>
</div>
</div>
</div>
{/* Future migrations */}
{futureMigrations.length > 0 && (
<>
{visibleFutureMigrations.map((migration) => (
<div key={migration.id} className="relative mt-6 w-full">
<MigrationStateCircle
actor={actor}
migration={migration}
nxConsoleMetadata={nxConsoleMetadata}
onClick={() => toggleMigrationExpanded(migration.id)}
/>
<div
className={twMerge(
`mt-1 ml-6`,
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="flex items-center gap-2 rounded-md border border-red-500 bg-red-500 px-4 py-2 text-sm font-medium text-white shadow-xs hover:bg-red-600 dark:border-red-700 dark:bg-red-600 hover:dark:bg-red-700"
>
<XMarkIcon
className="h-5 w-5"
aria-hidden="true"
/>{' '}
Skip
</button>
</div>
)}
</div>
<Collapsible
isOpen={expandedMigrations[migration.id]}
className="mt-2 w-full rounded-md border border-slate-300/50 p-3"
>
<MigrationCard
actor={actor}
migration={migration}
nxConsoleMetadata={nxConsoleMetadata}
isExpanded={expandedMigrations[migration.id]}
onFileClick={(file) => onFileClick(migration, file)}
onViewImplementation={() =>
onViewImplementation(migration)
}
onViewDocumentation={() =>
onViewDocumentation(migration)
}
onViewPrompt={() => onViewPrompt(migration)}
/>
</Collapsible>
</div>
</div>
))}
{hasFutureMigrationsHidden && (
<div
key="show-future-migrations"
className="relative mt-9 mb-1 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 mt-6 mb-1 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 top-0 left-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 {
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
migration: MigrationDetailsWithId;
nxConsoleMetadata: MigrationsJsonMetadata;
isCurrent?: boolean;
needsAttention?: boolean;
onClick: () => void;
}
function MigrationStateCircle({
actor,
migration,
nxConsoleMetadata,
isCurrent,
needsAttention,
onClick,
}: MigrationStateCircleProps) {
let bgClassName = '';
let textColor = '';
let Icon = null;
const isSkipped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'skipped'
);
const isRunning = useSelector(actor, (state) =>
isMigrationRunning(state.context, migration.id)
);
const isStopped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'stopped'
);
const isFailed = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'failed'
);
const isSuccess = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'successful'
);
const completed = nxConsoleMetadata.completedMigrations?.[migration.id];
const acknowledgedPrompt =
completed?.type === 'successful' && !!completed.acknowledgedPrompt;
const showHybridPendingAck =
isHybridShape(migration) && isSuccess && !acknowledgedPrompt;
if (isRunning) {
// Prioritize running state - show spinner even if metadata still shows stopped
bgClassName = 'bg-blue-500';
textColor = 'text-white';
Icon = ClockIcon;
} else if (isSkipped) {
bgClassName = 'bg-slate-300';
textColor = 'text-slate-700';
Icon = MinusIcon;
} else if (isFailed) {
bgClassName = 'bg-red-500';
textColor = 'text-white';
Icon = ExclamationCircleIcon;
} else if (isStopped) {
bgClassName = 'bg-yellow-500';
textColor = 'text-white';
Icon = XMarkIcon;
} else if (isSuccess) {
bgClassName = 'bg-green-500';
textColor = 'text-white';
Icon = CheckCircleIcon;
} else if (isCurrent && isPromptOnlyShape(migration)) {
// Prompt-only pending: AI-blue circle with white clock until the user
// marks it complete (which flips it to the success branch above). Only
// for the current migration — future ones get the uniform gray dot.
bgClassName = 'bg-sky-400';
textColor = 'text-white';
Icon = ClockIcon;
} else {
// Future migration (none of the states above)
bgClassName = 'bg-slate-300';
textColor = 'text-slate-700';
}
return (
<div
className={twMerge(
!!Icon ? 'h-8 w-8' : 'mt-1 h-6 w-6',
`absolute top-0 left-0 flex -translate-x-1/2 cursor-pointer items-center justify-center rounded-full ${bgClassName} ${textColor}`,
needsAttention ? 'animate-pulse' : ''
)}
onClick={onClick}
>
{isRunning ? (
<span className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : Icon ? (
<Icon className="h-6 w-6" />
) : null}
{showHybridPendingAck && (
<span
className="absolute -right-0.5 -bottom-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-white dark:bg-slate-900"
aria-hidden="true"
>
<ClockIcon className="h-3 w-3 text-sky-300" />
</span>
)}
</div>
);
}
+566
View File
@@ -0,0 +1,566 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import { useState } from 'react';
import { MigrateUI } from './migrate';
import {
isPromptOnlyShape,
type MigrationsJsonMetadata,
} from './migration-shape';
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);
},
onViewPrompt: (migration) => {
console.log('view prompt', migration);
},
onViewDocumentation: (migration) => {
console.log('view documentation', migration);
},
onCancel: () => {
console.log('cancel');
},
onFinish: (squash: boolean) => {
console.log('finished', squash);
},
},
};
export const PendingApproval: Story = {
args: {
currentMigrationId: 'migration-2',
migrations: [
{
id: 'migration-1',
name: 'migration-1',
description: 'This is a test migration',
version: '1.0.0',
package: 'nx',
implementation: './src/migrations/migration-1.ts',
},
{
id: 'migration-2',
name: 'migration-2',
description: 'This is a test migration',
version: '1.0.1',
package: '@nx/react',
implementation: './src/migrations/migration-2.ts',
},
{
id: 'migration-3',
name: 'migration-3',
description: 'Migrate 3',
version: '1.0.1',
package: '@nx/react',
implementation: './src/migrations/migration-3.ts',
},
],
nxConsoleMetadata: {
completedMigrations: {
'migration-1': {
name: 'migration-1',
type: 'successful',
changedFiles: [],
ref: '123',
nextSteps: [],
},
'migration-2': {
name: 'migration-2',
type: 'successful',
changedFiles: [{ path: 'foo.txt', type: 'CREATE' }],
ref: '124',
nextSteps: [
'Check something: https://nx.dev/docs',
'Check another thing: https://nx.dev/docs',
],
},
},
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);
},
onUndoMigration: (migration) => {
console.log('undo migration', migration);
},
onFileClick: (migration, file) => {
console.log('file click', migration, file);
},
onViewImplementation: (migration) => {
console.log('view implementation', migration);
},
onViewPrompt: (migration) => {
console.log('view prompt', migration);
},
onViewDocumentation: (migration) => {
console.log('view documentation', migration);
},
onCancel: () => {
console.log('cancel');
},
onFinish: (squash: boolean) => {
console.log('finished', squash);
},
},
};
export const PromptBearing: Story = {
args: {
// Ordering reflects the realistic flow: past migrations have already been
// processed (auto-run or approved), the current one is paused on review,
// and future ones are still waiting to be auto-run.
currentMigrationId: 'hybrid-pending-ack',
migrations: [
{
id: 'hybrid-completed',
name: 'update-imports',
description:
'Updates legacy imports, then prompts for manual import cleanup.',
version: '19.0.0',
package: '@nx/react',
implementation: './src/migrations/update-imports.ts',
prompt: 'tools/ai-migrations/@nx/react/19.0.0/import-cleanup.md',
},
{
id: 'prompt-only-completed',
name: 'adopt-new-router',
description: 'Adopt the new router API across the workspace.',
version: '19.0.0',
package: '@nx/react',
prompt: 'tools/ai-migrations/@nx/react/19.0.0/new-router.md',
},
{
id: 'hybrid-pending-ack',
name: 'update-jsx-runtime',
description:
'Updates JSX runtime config, then prompts for manual JSX refactor.',
version: '19.0.0',
package: '@nx/react',
implementation: './src/migrations/update-jsx-runtime.ts',
prompt: 'tools/ai-migrations/@nx/react/19.0.0/jsx-refactor.md',
},
{
id: 'prompt-only-pending',
name: 'migrate-class-components-to-hooks',
description:
'Convert class components to functional components using hooks.',
version: '19.0.0',
package: '@nx/react',
prompt: 'tools/ai-migrations/@nx/react/19.0.0/class-to-hooks.md',
},
],
nxConsoleMetadata: {
completedMigrations: {
// The prompt-path next step is rendered by the card from
// `migration.prompt` — recorded nextSteps only carry what the
// generator itself returned.
'hybrid-pending-ack': {
name: 'update-jsx-runtime',
type: 'successful',
changedFiles: [{ path: 'tsconfig.json', type: 'UPDATE' }],
ref: 'abc',
nextSteps: [
'Review the updated JSX runtime configuration in tsconfig.json.',
],
},
'hybrid-completed': {
name: 'update-imports',
type: 'successful',
changedFiles: [{ path: 'src/app.tsx', type: 'UPDATE' }],
ref: 'def',
nextSteps: [],
acknowledgedPrompt: true,
},
'prompt-only-completed': {
name: 'adopt-new-router',
type: 'successful',
changedFiles: [],
ref: 'ghi',
nextSteps: [],
},
},
targetVersion: '19.0.0',
},
},
// Storybook has no backend, so `onAcknowledgePrompt` mirrors what
// `acknowledgeMigrationPrompt` does server-side — records success for
// prompt-only, sets the ack flag for hybrid — so the full lifecycle is
// visible end-to-end. The state machine never auto-runs anything in this
// all-prompt-bearing story, so `onRunMigration` stays a logger.
render: function PromptBearingStory(args) {
const [metadata, setMetadata] = useState<MigrationsJsonMetadata>(
args.nxConsoleMetadata
);
const onAcknowledgePrompt = (migration: MigrationDetailsWithId) => {
setMetadata((prev) => {
if (isPromptOnlyShape(migration)) {
return {
...prev,
completedMigrations: {
...prev.completedMigrations,
[migration.id]: {
type: 'successful',
name: migration.id,
changedFiles: [],
ref: 'simulated',
nextSteps: [],
},
},
};
}
const existing = prev.completedMigrations?.[migration.id];
if (existing?.type !== 'successful') return prev;
return {
...prev,
completedMigrations: {
...prev.completedMigrations,
[migration.id]: { ...existing, acknowledgedPrompt: true },
},
};
});
};
return (
<MigrateUI
{...args}
nxConsoleMetadata={metadata}
onAcknowledgePrompt={onAcknowledgePrompt}
onRunMigration={(migration, configuration) =>
console.log('run migration', migration, configuration)
}
onRunMany={(migrations, configuration) =>
console.log('run many migrations', migrations, configuration)
}
onSkipMigration={(migration) =>
console.log('skip migration', migration)
}
onUndoMigration={(migration) =>
console.log('undo migration', migration)
}
onFileClick={(migration, file) =>
console.log('file click', migration, file)
}
onViewImplementation={(migration) =>
console.log('view implementation', migration)
}
onViewPrompt={(migration) => console.log('view prompt', migration)}
onViewDocumentation={(migration) =>
console.log('view documentation', migration)
}
onCancel={() => console.log('cancel')}
onFinish={(squash) => console.log('finished', squash)}
onStopMigration={(migration) =>
console.log('stop migration', migration)
}
/>
);
},
};
export const MigrationStopped: Story = {
args: {
currentMigrationId: 'migration-2',
migrations: [
{
id: 'migration-1',
name: 'migration-1',
description: 'This is a test migration',
version: '1.0.0',
package: 'nx',
implementation: './src/migrations/migration-1.ts',
},
{
id: 'migration-2',
name: 'migration-2',
description: 'This is a test migration',
version: '1.0.1',
package: '@nx/react',
implementation: './src/migrations/migration-2.ts',
},
{
id: 'migration-3',
name: 'migration-3',
description: 'Migrate 3',
version: '1.0.1',
package: '@nx/react',
implementation: './src/migrations/migration-3.ts',
},
],
nxConsoleMetadata: {
completedMigrations: {
'migration-1': {
name: 'migration-1',
type: 'successful',
changedFiles: [],
ref: '123',
nextSteps: [],
},
'migration-2': {
name: 'migration-2',
type: 'stopped',
error: 'Migration was stopped by the user',
},
},
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);
},
onUndoMigration: (migration) => {
console.log('undo migration', migration);
},
onFileClick: (migration, file) => {
console.log('file click', migration, file);
},
onViewImplementation: (migration) => {
console.log('view implementation', migration);
},
onViewPrompt: (migration) => {
console.log('view prompt', migration);
},
onViewDocumentation: (migration) => {
console.log('view documentation', migration);
},
onCancel: () => {
console.log('cancel');
},
onFinish: (squash: boolean) => {
console.log('finished', squash);
},
onStopMigration: (migration) => {
console.log('stop migration', migration);
},
},
};
+336
View File
@@ -0,0 +1,336 @@
// 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';
import { useEffect, useState } from 'react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { Popover } from '@nx/graph-ui-common';
import type { MigrationsJsonMetadata } from './migration-shape';
import { useInterpret, useSelector } from '@xstate/react';
import { machine as automaticMigrationMachine } from './state/automatic/machine';
import {
MigrationInit,
MigrationDone,
MigrationSettingsPanel,
AutomaticMigration,
} from './components';
import { getCurrentMigrationType } from './state/automatic/selectors';
export interface MigrateUIProps {
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
currentMigrationId?: string;
onRunMigration: (
migration: MigrationDetailsWithId,
configuration: {
createCommits: boolean;
}
) => void;
onRunMany: (
migrations: MigrationDetailsWithId[],
configuration: {
createCommits: boolean;
commitPrefix: string;
}
) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onUndoMigration: (migration: MigrationDetailsWithId) => void;
onStopMigration: (migration: MigrationDetailsWithId) => void;
onAcknowledgePrompt: (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;
onViewPrompt: (migration: MigrationDetailsWithId) => void;
}
export enum PrimaryAction {
RunMigrations = 'Run Migrations',
ApproveChanges = 'Approve Changes to Continue',
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 currentMigration = useSelector(
actor,
(state) => state.context.currentMigration
);
const isDone = useSelector(actor, (state) => state.matches('done'));
const isInit = useSelector(actor, (state) => state.matches('init'));
const isRunning = useSelector(actor, (state) => state.matches('running'));
const isCurrentMigrationStopped = useSelector(
actor,
(state) => getCurrentMigrationType(state.context) === 'stopped'
);
const isCurrentMigrationFailed = useSelector(
actor,
(state) => getCurrentMigrationType(state.context) === 'failed'
);
const isNeedReview = useSelector(actor, (state) =>
state.matches('needsReview')
);
useEffect(() => {
actor.send({
type: 'loadInitialData',
migrations: props.migrations,
metadata: props.nxConsoleMetadata,
currentMigrationId: props.currentMigrationId,
});
// 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.ApproveChanges ||
primaryAction === PrimaryAction.RunMigrations) &&
!isInit
) {
setPrimaryAction(
isNeedReview && !isCurrentMigrationFailed
? PrimaryAction.ApproveChanges
: PrimaryAction.RunMigrations
);
}
}, [
isRunning,
primaryAction,
isInit,
isNeedReview,
isCurrentMigrationFailed,
]);
const handleStartMigration = () => {
actor.send({ type: 'startRunning' });
};
const handlePrimaryActionSelection = () => {
if (primaryAction === PrimaryAction.RunMigrations) {
handleStartMigration();
} else if (
primaryAction === PrimaryAction.FinishWithoutSquashingCommits ||
primaryAction === PrimaryAction.FinishSquashingCommits
) {
props.onFinish(primaryAction === PrimaryAction.FinishSquashingCommits);
}
};
const handleStopMigration = () => {
if (!currentMigration) {
return;
}
props.onStopMigration(currentMigration);
};
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={props.onSkipMigration}
onUndoMigration={props.onUndoMigration}
onAcknowledgePrompt={props.onAcknowledgePrompt}
onViewImplementation={props.onViewImplementation}
onViewDocumentation={props.onViewDocumentation}
onViewPrompt={props.onViewPrompt}
onFileClick={props.onFileClick}
/>
</div>
<div className="bottom-0 flex shrink-0 justify-end gap-2 bg-transparent py-4">
<button
onClick={props.onCancel}
type="button"
className="flex items-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-xs 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 gap-2">
<button
onClick={handleStopMigration}
disabled={isCurrentMigrationStopped || isNeedReview}
type="button"
className={`flex items-center rounded-md border px-4 py-2 text-sm font-medium shadow-sm ${
isCurrentMigrationStopped || isNeedReview
? 'cursor-not-allowed border-gray-300 bg-gray-300 text-gray-500 dark:border-gray-600 dark:bg-gray-600 dark:text-gray-400'
: 'border-red-300 bg-red-500 text-white hover:bg-red-600 dark:border-red-600 dark:bg-red-600 hover:dark:bg-red-700'
}`}
>
Stop Migration
</button>
<div className="group flex">
<button
onClick={handlePrimaryActionSelection}
type="button"
title={primaryAction}
disabled={isNeedReview}
className="rounded-l-md border border-blue-700 bg-blue-500 px-4 py-2 text-sm font-medium whitespace-nowrap text-white shadow-xs enabled:hover:bg-blue-600 disabled:cursor-not-allowed disabled:border-blue-400 disabled:bg-blue-400 disabled:opacity-50 dark:border-blue-700 dark:bg-blue-600 dark:text-white enabled:hover:dark:bg-blue-700"
>
{primaryAction}
</button>
<div className="relative flex">
<button
type="button"
disabled={isNeedReview}
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center rounded-r-md border border-l-1 border-blue-700 bg-blue-500 px-2 py-2 text-sm font-medium text-white shadow-xs enabled:hover:bg-blue-700 disabled:cursor-not-allowed disabled:border-blue-400 disabled:bg-blue-400 disabled:opacity-50 dark:border-blue-700 dark:bg-blue-700 dark:text-white enabled: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">
<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>
<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;
+66
View File
@@ -0,0 +1,66 @@
// nx-ignore-next-line
import type {
MigrationsJsonMetadata as NxMigrationsJsonMetadata,
SuccessfulMigration as NxSuccessfulMigration,
FailedMigration,
SkippedMigration,
StoppedMigration,
} from 'nx/src/command-line/migrate/migrate-ui-api';
// Mirrors `packages/nx/src/command-line/migrate/migration-shape.ts`. graph/*
// keeps its runtime decoupled from `nx`: existing `nx-ignore-next-line` imports
// from `nx/src/...` are type-only (erased at build), and the bundle ships in
// hosts that target multiple nx versions (the embedded `nx migrate` UI plus
// Nx Console's snapshot). Importing these helpers from `nx` would either drag
// node-only modules into the browser bundle (via `migrate-ui-api`) or assume a
// symbol that older nx versions don't export (`migration-shape` itself only
// exists from 23.0.0-beta.20+). Mirroring keeps the shape check self-contained
// and degrades cleanly: on older metadata, `prompt` is undefined and both
// predicates return false, so prompt UI stays hidden. `factory` is the legacy
// Angular-schematics field, treated equivalently to `implementation`.
type MigrationShape = {
prompt?: string;
implementation?: string;
factory?: string;
};
function hasDeterministicImplementation(m: MigrationShape): boolean {
return !!(m.implementation || m.factory);
}
export function isPromptOnlyShape(m: MigrationShape): boolean {
return !!m.prompt && !hasDeterministicImplementation(m);
}
export function isHybridShape(m: MigrationShape): boolean {
return !!m.prompt && hasDeterministicImplementation(m);
}
// TODO(nxc-4477): drop these augmentations once the installed `nx` baseline in
// this workspace (currently 23.0.0-beta.18) includes `acknowledgedPrompt` on
// `SuccessfulMigration` and switch call sites back to importing these types
// directly from `nx/src/command-line/migrate/migrate-ui-api`. graph-migrate
// has no real `nx` dep (would close a graph cycle via
// nx → graph-client → graph-migrate), so its type imports resolve against the
// installed-nx baseline; the local augmentation surfaces the new field
// without forcing a workspace-level nx dep. On older runtime nx versions the
// field is undefined and the UI degrades to "no prompt acknowledged".
//
// `MigrationsJsonMetadata` is also mirrored so the augmented `SuccessfulMigration`
// flows through `completedMigrations` narrowing — call sites get the new field
// without per-site casts. Listing the union members locally is intentional: if
// upstream adds a new variant, the narrower local type will fail to accept
// inbound values at the consumer boundary, forcing us to handle it.
export type SuccessfulMigration = NxSuccessfulMigration & {
acknowledgedPrompt?: boolean;
};
export type MigrationsJsonMetadata = Omit<
NxMigrationsJsonMetadata,
'completedMigrations'
> & {
completedMigrations?: Record<
string,
SuccessfulMigration | FailedMigration | SkippedMigration | StoppedMigration
>;
};
@@ -0,0 +1,63 @@
import { AutomaticMigrationState } from './types';
import {
getCurrentMigrationType,
currentMigrationCanLeaveReview,
currentMigrationHasChanges,
currentMigrationIsRunning,
} from './selectors';
import { isPromptOnlyShape } from '../../migration-shape';
export const guards = {
canStartRunningCurrentMigration: (ctx: AutomaticMigrationState) => {
const type = getCurrentMigrationType(ctx);
return (
!!ctx.currentMigration &&
!currentMigrationIsRunning(ctx) &&
!(type === 'skipped') &&
// Allow running if migration is not completed
// stopped migrations can be restarted via the UI, Nx-console will handle the state change
!(type === 'failed') &&
// An already-successful entry should never re-spawn the generator —
// it falls to needsReview instead (where the user acks for hybrid or
// the machine increments past for regular).
!(type === 'successful') &&
!currentMigrationHasChanges(ctx) &&
// Nx Console can't run AI prompts — prompt-only migrations require the
// user to explicitly Approve once they've run the prompt externally.
!isPromptOnlyShape(ctx.currentMigration)
);
},
currentMigrationIsDone: (ctx: AutomaticMigrationState) => {
const type = getCurrentMigrationType(ctx);
return (
!!ctx.currentMigration &&
!currentMigrationIsRunning(ctx) &&
((type === 'successful' && currentMigrationCanLeaveReview(ctx)) ||
type === 'skipped')
);
},
currentMigrationCanLeaveReview: (ctx: AutomaticMigrationState) =>
currentMigrationCanLeaveReview(ctx),
lastMigrationIsDone: (ctx: AutomaticMigrationState) => {
if (!ctx.migrations || !ctx.currentMigration) return false;
const currentIndex = ctx.migrations.findIndex(
(m) => m.id === ctx.currentMigration?.id
);
return (
currentIndex === ctx.migrations.length - 1 &&
currentMigrationCanLeaveReview(ctx)
);
},
needsReview: (ctx: AutomaticMigrationState) => {
return (
!currentMigrationIsRunning(ctx) &&
!guards.canStartRunningCurrentMigration(ctx)
);
},
};
@@ -0,0 +1,364 @@
import { interpret } from 'xstate';
import { machine } from './machine';
// nx-ignore-next-line
import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import type { MigrationsJsonMetadata } from '../../migration-shape';
const makeMigration = (id: string): MigrationDetailsWithId => ({
id,
name: `migration-${id}`,
version: '1.0.0',
package: 'test',
description: 'Test migration for ' + id,
implementation: './migrations/' + id + '.js',
});
const baseMetadata: MigrationsJsonMetadata = {
completedMigrations: {},
};
describe('automatic migration state machine', () => {
it('starts in init', () => {
const service = interpret(machine).start();
expect(service.state.value).toBe('init');
});
it('transitions to evaluate from startRunning', () => {
const migration = makeMigration('a');
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata: baseMetadata,
});
service.send({ type: 'startRunning' });
// Should move to evaluate first then end in running
expect(service.state.value).toBe('running');
});
it('enters done if the last migration is reviewed', () => {
const migration = makeMigration('a');
const metadata: MigrationsJsonMetadata = {
completedMigrations: {
[migration.id]: {
type: 'successful',
changedFiles: [],
name: migration.id,
ref: 'migration-ref',
},
},
};
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata,
});
service.send({ type: 'startRunning' });
service.send({ type: 'reviewMigration', migrationId: migration.id });
expect(service.state.value).toBe('done');
});
it('enters needsReview if there are file changes', () => {
const migration = makeMigration('a');
const metadata: MigrationsJsonMetadata = {
completedMigrations: {
[migration.id]: {
type: 'successful',
changedFiles: [{ path: 'file.ts', type: 'CREATE' }],
name: migration.id,
ref: 'migration-ref',
},
},
};
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata,
});
service.send({ type: 'startRunning' });
expect(service.state.value).toBe('needsReview');
});
it('goes to next migration after review', () => {
const m1 = makeMigration('a');
const m2 = makeMigration('b');
const metadata: MigrationsJsonMetadata = {
completedMigrations: {
[m1.id]: {
type: 'successful',
changedFiles: [],
name: m1.id,
ref: 'migration-a',
},
},
};
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [m1, m2],
metadata,
});
service.send({ type: 'startRunning' });
service.send({ type: 'reviewMigration', migrationId: m1.id });
expect(service.state.context.currentMigration?.id).toBe(m2.id);
expect(service.state.value).toBe('running');
});
it('transitions to running state when startRunning is sent', () => {
const service = interpret(machine).start();
const migration = makeMigration('a');
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata: baseMetadata,
});
service.send({ type: 'startRunning' });
expect(service.state.value).toBe('running');
});
it('transitions to evaluate when stopped via stop event', () => {
const migration = makeMigration('a');
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata: baseMetadata,
});
service.send({ type: 'startRunning' });
// Verify we're in running state first
expect(service.state.value).toBe('running');
service.send({ type: 'stop' });
// The stop event transitions out of running, but we need to update metadata
// to reflect the actual stopped state from the backend
service.send({
type: 'updateMetadata',
metadata: {
completedMigrations: {
[migration.id]: {
type: 'stopped' as const,
name: migration.id,
error: 'Manually stopped',
},
},
},
});
// After metadata update, should be in stopped state
expect(service.state.value).toBe('stopped');
});
it('transitions to stopped when stopped via metadata update', () => {
const migration = makeMigration('a');
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata: baseMetadata,
});
service.send({ type: 'startRunning' });
// Verify we're in running state first
expect(service.state.value).toBe('running');
// Update metadata to mark migration as stopped
service.send({
type: 'updateMetadata',
metadata: {
completedMigrations: {
[migration.id]: {
type: 'stopped' as const,
name: migration.id,
error: 'Manually stopped',
},
},
},
});
// After updateMetadata, state should be stopped
expect(service.state.value).toBe('stopped');
});
it('can restart and continue with multiple migrations after stop', () => {
const m1 = makeMigration('migration-1');
const m2 = makeMigration('migration-2');
const m3 = makeMigration('migration-3');
const service = interpret(machine).start();
// Load 3 migrations with first one already completed
const initialMetadata = {
completedMigrations: {
[m1.id]: {
type: 'successful' as const,
changedFiles: [],
name: m1.id,
ref: 'ref-1',
},
},
};
service.send({
type: 'loadInitialData',
migrations: [m1, m2, m3],
metadata: initialMetadata,
});
service.send({ type: 'startRunning' });
// Should start with migration-2 (since migration-1 is completed)
expect(service.state.value).toBe('running');
expect(service.state.context.currentMigration?.id).toBe(m2.id);
// Stop the migration while running migration-2
service.send({ type: 'stop' });
// Update metadata to reflect the migration is stopped (this would come from backend)
service.send({
type: 'updateMetadata',
metadata: {
completedMigrations: {
[m1.id]: {
type: 'successful' as const,
changedFiles: [],
name: m1.id,
ref: 'ref-1',
},
[m2.id]: {
type: 'stopped' as const,
name: m2.id,
error: 'Manually stopped',
},
},
},
});
// Should be in stopped state
expect(service.state.value).toBe('stopped');
expect(service.state.context.currentMigration?.id).toBe(m2.id);
// Clear the stopped status from metadata (simulating user clearing the stop)
service.send({
type: 'updateMetadata',
metadata: initialMetadata, // Back to just having m1 completed
});
// Now restart - should continue with migration-2
service.send({ type: 'startRunning' });
// Should resume running migration-2
expect(service.state.value).toBe('running');
expect(service.state.context.currentMigration?.id).toBe(m2.id);
});
it('skips migrations marked as skipped and moves to next migration', () => {
const m1 = makeMigration('migration-1');
const m2 = makeMigration('migration-2');
const m3 = makeMigration('migration-3');
const metadata: MigrationsJsonMetadata = {
completedMigrations: {
[m2.id]: {
type: 'skipped',
},
},
};
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [m1, m2, m3],
metadata,
});
service.send({ type: 'startRunning' });
// Should start with m1 (first incomplete migration)
expect(service.state.value).toBe('running');
expect(service.state.context.currentMigration?.id).toBe(m1.id);
// Complete m1 successfully without changes
service.send({
type: 'updateMetadata',
metadata: {
completedMigrations: {
[m1.id]: {
type: 'successful',
changedFiles: [],
name: m1.id,
ref: 'ref-1',
},
[m2.id]: {
type: 'skipped',
},
},
},
});
// After m1 completes, machine should move to m2. Since m2 is skipped,
// it should be considered "done" and automatically increment to m3
expect(service.state.value).toBe('running');
expect(service.state.context.currentMigration?.id).toBe(m3.id);
});
it('does not attempt to run skipped migrations', () => {
const migration = makeMigration('skipped-migration');
const metadata: MigrationsJsonMetadata = {
completedMigrations: {
[migration.id]: {
type: 'skipped',
},
},
};
const service = interpret(machine).start();
service.send({
type: 'loadInitialData',
migrations: [migration],
metadata,
});
service.send({ type: 'startRunning' });
// Should go to done state since the only migration is skipped
expect(service.state.value).toBe('done');
expect(service.state.context.currentMigration?.id).toBe(migration.id);
});
});
@@ -0,0 +1,165 @@
import { createMachine } from 'xstate';
import { assign } from '@xstate/immer';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from './types';
import { guards } from './guards';
import { findFirstIncompleteMigration } from './selectors';
export const machine = createMachine<
AutomaticMigrationState,
AutomaticMigrationEvents
>(
{
id: 'migrate',
initial: 'init',
predictableActionArguments: true,
preserveActionOrder: true,
context: {
reviewedMigrations: [],
},
states: {
init: {
on: {
startRunning: {
target: 'evaluate',
},
},
},
evaluate: {
always: [
{
cond: 'lastMigrationIsDone',
target: 'done',
},
{
cond: 'currentMigrationIsDone',
target: 'increment',
},
{
cond: 'canStartRunningCurrentMigration',
target: 'running',
},
{
cond: 'needsReview',
target: 'needsReview',
},
],
},
increment: {
entry: 'incrementCurrentMigration',
always: 'evaluate',
},
running: {
entry: ['runMigration'],
on: {
stop: {
target: 'evaluate',
},
},
},
stopped: {
on: {
startRunning: {
target: 'running',
},
},
},
needsReview: {
on: {
reviewMigration: {
target: 'evaluate',
actions: assign((ctx, event) => {
ctx.reviewedMigrations.push(event.migrationId);
}),
},
startRunning: {
target: 'evaluate',
},
},
},
done: {},
},
on: {
loadInitialData: {
actions: assign((ctx, event) => {
ctx.migrations = event.migrations;
ctx.nxConsoleMetadata = event.metadata;
ctx.currentMigration =
event.migrations.find((m) => m.id === event.currentMigrationId) ??
findFirstIncompleteMigration(event.migrations, event.metadata);
}),
},
updateMetadata: [
{
cond: (ctx, event) => {
const type =
event.metadata.completedMigrations?.[
ctx.currentMigration?.id ?? ''
]?.type;
return type === 'stopped';
},
target: 'stopped',
actions: assign((ctx, event) => {
ctx.nxConsoleMetadata = event.metadata;
}),
},
{
cond: (ctx, event) => {
const type =
event.metadata.completedMigrations?.[
ctx.currentMigration?.id ?? ''
]?.type;
return (
type === 'failed' || type === 'skipped' || type === 'successful'
);
},
target: 'evaluate',
actions: assign((ctx, event) => {
ctx.nxConsoleMetadata = event.metadata;
}),
},
{
actions: assign((ctx, event) => {
ctx.nxConsoleMetadata = event.metadata;
}),
},
],
reviewMigration: {
actions: assign((ctx, event) => {
ctx.reviewedMigrations = [
...ctx.reviewedMigrations,
event.migrationId,
];
}),
},
},
},
{
guards,
actions: {
incrementCurrentMigration: assign((ctx) => {
if (!ctx.migrations || !ctx.currentMigration) return;
const index = ctx.migrations.findIndex(
(m) => m.id === ctx.currentMigration?.id
);
if (index < ctx.migrations.length - 1) {
ctx.currentMigration = ctx.migrations[index + 1];
}
}),
runMigration: () => {
// No-op, the actual action is handled in the UI
},
},
}
);
@@ -0,0 +1,97 @@
// nx-ignore-next-line
import { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import type { AutomaticMigrationState } from './types';
import { isHybridShape, MigrationsJsonMetadata } from '../../migration-shape';
export function findFirstIncompleteMigration(
migrations: MigrationDetailsWithId[],
nxConsoleMetadata: MigrationsJsonMetadata
) {
return (
migrations.find((migration) => {
const data = nxConsoleMetadata.completedMigrations?.[migration.id];
if (!data) return true;
if (data.type === 'skipped') return false;
if (data.type === 'successful') {
// Hybrid migrations still need the user's AI-prompt acknowledgement
// before they count as complete — otherwise a session reopened after
// generator success would skip past them and the prompt would never
// be acked.
return isHybridShape(migration) && !data.acknowledgedPrompt;
}
return true;
}) ?? migrations[0]
);
}
export function getMigrationType(
ctx: AutomaticMigrationState,
migrationId: string
) {
return ctx.nxConsoleMetadata?.completedMigrations?.[migrationId]?.type;
}
export function getCurrentMigrationType(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) return undefined;
return getMigrationType(ctx, ctx.currentMigration.id);
}
export function getMigrationCompletedData(
ctx: AutomaticMigrationState,
migrationId: string
) {
return ctx.nxConsoleMetadata?.completedMigrations?.[migrationId];
}
export function getCurrentMigrationCompletedData(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) return undefined;
return getMigrationCompletedData(ctx, ctx.currentMigration.id);
}
export function currentMigrationHasChanges(ctx: AutomaticMigrationState) {
const completed = getCurrentMigrationCompletedData(ctx);
return (
completed?.type === 'successful' &&
(completed.changedFiles?.length ?? 0) > 0
);
}
export function currentMigrationIsReviewed(ctx: AutomaticMigrationState) {
return (
!!ctx.currentMigration &&
ctx.reviewedMigrations.includes(ctx.currentMigration.id)
);
}
export function currentMigrationCanLeaveReview(ctx: AutomaticMigrationState) {
const type = getCurrentMigrationType(ctx);
if (type === 'skipped') return true;
// Metadata-recorded success is the gate — the local `reviewedMigrations`
// flag is a precondition for hybrid-with-changes, never a substitute. This
// prevents the prompt-only/hybrid-no-changes race where the user clicks
// Mark as Run, the reviewed flag is set synchronously, and the machine
// advances to `done` before the backend has written the successful entry.
if (type !== 'successful') return false;
// Hybrid migrations need the user's AI-prompt acknowledgement before they
// can leave review — even if the generator produced no diff.
if (ctx.currentMigration && isHybridShape(ctx.currentMigration)) {
const data = getCurrentMigrationCompletedData(ctx);
if (data?.type === 'successful' && !data.acknowledgedPrompt) return false;
}
return !currentMigrationHasChanges(ctx) || currentMigrationIsReviewed(ctx);
}
export function isMigrationRunning(
ctx: AutomaticMigrationState,
migrationId: string
) {
return (
ctx.nxConsoleMetadata?.runningMigrations?.includes(migrationId) ?? false
);
}
export function currentMigrationIsRunning(ctx: AutomaticMigrationState) {
if (!ctx.currentMigration) return false;
return isMigrationRunning(ctx, ctx.currentMigration.id);
}

Some files were not shown because too many files have changed in this diff Show More