chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
Vendored
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) => { ... })
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -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'.
|
||||
@@ -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-*'],
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: [require('@tailwindcss/postcss'), require('autoprefixer')],
|
||||
};
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 |
Vendored
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
import './tailwind.css';
|
||||
export const tags = ['autodocs'];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user