diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts index f8ab655e83..74bf5b0306 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts @@ -144,11 +144,11 @@ export class CreateRunPage { } fillName(value: string): void { - this.findNameInput().type(value); + this.findNameInput().clear().type(value); } fillDescription(value: string): void { - this.findDescriptionInput().type(value); + this.findDescriptionInput().clear().type(value); } selectExperimentByName(name: string): void { diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelinesTable.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelinesTable.ts index 332c959859..5ca6fb8446 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelinesTable.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelinesTable.ts @@ -1,5 +1,5 @@ /* eslint-disable camelcase */ -import type { PipelineKFv2, PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; +import { type PipelineKFv2, type PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; import { buildMockPipelines } from '~/__mocks__/mockPipelinesProxy'; import { buildMockPipelineVersionsV2 } from '~/__mocks__/mockPipelineVersionsProxy'; import { TableRow } from '~/__tests__/cypress/cypress/pages/components/table'; @@ -217,21 +217,55 @@ class PipelinesTable { ); } - mockGetPipelines(pipelines: PipelineKFv2[], namespace: string) { + mockGetPipelines(pipelines: PipelineKFv2[], namespace: string, times?: number) { return cy.interceptOdh( 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines', { path: { namespace, serviceName: 'dspa' }, + times, + }, + (req) => { + const { filter } = req.query; + const predicates = filter ? JSON.parse(filter.toString())?.predicates : []; + const filterName = predicates?.[0]?.string_value; + + if (!filterName) { + req.reply(buildMockPipelines(pipelines)); + } else { + req.reply( + buildMockPipelines( + pipelines.filter((pipeline) => pipeline.display_name === filterName), + ), + ); + } }, - buildMockPipelines(pipelines), ); } - mockGetPipelineVersions(versions: PipelineVersionKFv2[], pipelineId: string, namespace: string) { + mockGetPipelineVersions( + versions: PipelineVersionKFv2[], + pipelineId: string, + namespace: string, + times?: number, + ) { return cy.interceptOdh( 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions', - { path: { namespace, serviceName: 'dspa', pipelineId } }, - buildMockPipelineVersionsV2(versions), + { path: { namespace, serviceName: 'dspa', pipelineId }, times }, + (req) => { + const { filter } = req.query; + const predicates = filter ? JSON.parse(filter.toString())?.predicates : []; + const filterName = predicates?.[0]?.string_value; + + if (!filterName) { + req.reply(buildMockPipelineVersionsV2(versions)); + } else { + req.reply( + buildMockPipelineVersionsV2( + versions.filter((version) => version.display_name === filterName), + ), + ); + } + }, ); } } diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index ac3a75d20b..87b3366567 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -389,7 +389,10 @@ declare global { ) => Cypress.Chainable) & (( type: `GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions`, - options: { path: { namespace: string; serviceName: string; pipelineId: string } }, + options: { + path: { namespace: string; serviceName: string; pipelineId: string }; + times?: number; + }, response: OdhResponse, ) => Cypress.Chainable) & (( @@ -397,6 +400,7 @@ declare global { options: { path: { namespace: string; serviceName: string }; query?: { sort_by: string }; + times?: number; }, response: OdhResponse, ) => Cypress.Chainable) & diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts index d4cc80d008..4440f9a56c 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts @@ -131,6 +131,8 @@ describe('Pipeline create runs', () => { createRunPage.find(); // Fill out the form without a schedule and submit + createRunPage.fillName(initialMockRuns[0].display_name); + cy.findByTestId('duplicate-name-help-text').should('be.visible'); createRunPage.fillName('New run'); createRunPage.fillDescription('New run description'); createRunPage.findExperimentSelect().should('not.be.disabled').click(); @@ -561,6 +563,8 @@ describe('Pipeline create runs', () => { createSchedulePage.find(); // Fill out the form with a schedule and submit + createRunPage.fillName(initialMockRecurringRuns[0].display_name); + cy.findByTestId('duplicate-name-help-text').should('be.visible'); createSchedulePage.fillName('New recurring run'); createSchedulePage.fillDescription('New recurring run description'); createSchedulePage.findExperimentSelect().should('not.be.disabled').click(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts index f2a5b582a7..92100e1b17 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts @@ -18,7 +18,6 @@ import { viewPipelineServerModal, PipelineSort, } from '~/__tests__/cypress/cypress/pages/pipelines'; -import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; import { DataSciencePipelineApplicationModel, @@ -364,8 +363,28 @@ describe('Pipelines', () => { }); it('View pipeline server', () => { - const visitPipelineProjects = () => pipelinesGlobal.visit(projectName); - viewPipelineServerDetailsTest(visitPipelineProjects); + initIntercepts({}); + cy.interceptK8s( + { + model: SecretModel, + ns: projectName, + }, + mockSecretK8sResource({ + s3Bucket: 'c2RzZA==', + namespace: projectName, + name: 'aws-connection-test', + }), + ); + pipelinesGlobal.visit(projectName); + + pipelinesGlobal.selectPipelineServerAction('View pipeline server configuration'); + viewPipelineServerModal.shouldHaveAccessKey('sdsd'); + viewPipelineServerModal.findPasswordHiddenButton().click(); + viewPipelineServerModal.shouldHaveSecretKey('sdsd'); + viewPipelineServerModal.shouldHaveEndPoint('https://s3.amazonaws.com'); + viewPipelineServerModal.shouldHaveBucketName('test-pipelines-bucket'); + + viewPipelineServerModal.findCloseButton().click(); }); it('renders the page with pipelines table data', () => { @@ -523,10 +542,10 @@ describe('Pipelines', () => { it('selects a different project', () => { initIntercepts({}); pipelinesGlobal.visit(projectName); - verifyRelativeURL('/pipelines/test-project-name'); + cy.url().should('include', '/pipelines/test-project-name'); pipelinesGlobal.selectProjectByName('Test Project 2'); - verifyRelativeURL('/pipelines/test-project-name-2'); + cy.url().should('include', '/pipelines/test-project-name-2'); }); it('imports a new pipeline', () => { @@ -540,9 +559,7 @@ describe('Pipelines', () => { // Intercept upload/re-fetch of pipelines pipelineImportModal.mockUploadPipeline(uploadPipelineParams, projectName).as('uploadPipeline'); - pipelinesTable - .mockGetPipelines([initialMockPipeline, uploadedMockPipeline], projectName) - .as('refreshPipelines'); + pipelinesTable.mockGetPipelines([initialMockPipeline], projectName); // Wait for the pipelines table to load pipelinesTable.find(); @@ -552,9 +569,14 @@ describe('Pipelines', () => { // Fill out the "Import pipeline" modal and submit pipelineImportModal.shouldBeOpen(); + pipelineImportModal.fillPipelineName(initialMockPipeline.display_name); + cy.findByTestId('duplicate-name-help-text').should('be.visible'); pipelineImportModal.fillPipelineName('New pipeline'); pipelineImportModal.fillPipelineDescription('New pipeline description'); pipelineImportModal.uploadPipelineYaml(pipelineYamlPath); + pipelinesTable + .mockGetPipelines([initialMockPipeline, uploadedMockPipeline], projectName) + .as('refreshPipelines'); pipelineImportModal.submit(); // Wait for upload/fetch requests @@ -574,7 +596,10 @@ describe('Pipelines', () => { }); cy.wait('@refreshPipelines').then((interception) => { - expect(interception.request.query).to.eql({ sort_by: 'created_at desc', page_size: '10' }); + expect(interception.request.query).to.eql({ + sort_by: 'created_at desc', + page_size: '10', + }); }); // Verify the uploaded pipeline is in the table @@ -587,6 +612,7 @@ describe('Pipelines', () => { pipelinesGlobal.findImportPipelineButton().click(); pipelineImportModal.shouldBeOpen(); + pipelinesTable.mockGetPipelines([initialMockPipeline], projectName, 1); pipelineImportModal.fillPipelineName('New pipeline'); pipelineImportModal.findUploadError().should('not.exist'); pipelineImportModal.findSubmitButton().should('be.disabled'); @@ -617,9 +643,7 @@ describe('Pipelines', () => { pipelineImportModal .mockCreatePipelineAndVersion(createPipelineAndVersionParams, projectName) .as('createPipelineAndVersion'); - pipelinesTable - .mockGetPipelines([initialMockPipeline, createdMockPipeline], projectName) - .as('refreshPipelines'); + pipelinesTable.mockGetPipelines([initialMockPipeline], projectName); pipelinesTable.mockGetPipelineVersions( [buildMockPipelineVersionV2(createPipelineAndVersionParams.pipeline_version)], 'new-pipeline', @@ -637,6 +661,9 @@ describe('Pipelines', () => { pipelineImportModal.fillPipelineName('New pipeline'); pipelineImportModal.findImportPipelineRadio().check(); pipelineImportModal.findPipelineUrlInput().type('https://example.com/pipeline.yaml'); + pipelinesTable + .mockGetPipelines([initialMockPipeline, createdMockPipeline], projectName) + .as('refreshPipelines'); pipelineImportModal.submit(); // Wait for upload/fetch requests @@ -668,13 +695,11 @@ describe('Pipelines', () => { pipelineVersionImportModal .mockUploadVersion(uploadVersionParams, projectName) .as('uploadVersion'); - pipelinesTable - .mockGetPipelineVersions( - [initialMockPipelineVersion, uploadedMockPipelineVersion], - initialMockPipeline.pipeline_id, - projectName, - ) - .as('refreshVersions'); + pipelinesTable.mockGetPipelineVersions( + [initialMockPipelineVersion], + initialMockPipeline.pipeline_id, + projectName, + ); // Fill out the "Upload new version" modal and submit pipelineVersionImportModal.shouldBeOpen(); @@ -682,6 +707,14 @@ describe('Pipelines', () => { pipelineVersionImportModal.fillVersionName('New pipeline version'); pipelineVersionImportModal.fillVersionDescription('New pipeline version description'); pipelineVersionImportModal.uploadPipelineYaml(pipelineYamlPath); + + pipelinesTable + .mockGetPipelineVersions( + [initialMockPipelineVersion, uploadedMockPipelineVersion], + initialMockPipeline.pipeline_id, + projectName, + ) + .as('refreshVersions'); pipelineVersionImportModal.submit(); // Wait for upload/fetch requests @@ -729,6 +762,7 @@ describe('Pipelines', () => { pipeline_url: 'https://example.com/pipeline.yaml', }, }; + // Wait for the pipelines table to load pipelinesTable.find(); @@ -745,7 +779,6 @@ describe('Pipelines', () => { projectName, ) .as('refreshVersions'); - pipelineVersionImportModal .mockCreatePipelineVersion(createPipelineVersionParams, projectName) .as('createVersion'); @@ -753,6 +786,14 @@ describe('Pipelines', () => { // Fill out the "Upload new version" modal and submit pipelineVersionImportModal.shouldBeOpen(); pipelineVersionImportModal.selectPipelineByName('Test pipeline'); + pipelinesTable.mockGetPipelineVersions( + [initialMockPipelineVersion], + initialMockPipeline.pipeline_id, + projectName, + 2, + ); + pipelineVersionImportModal.fillVersionName(initialMockPipelineVersion.display_name); + cy.findByTestId('duplicate-name-help-text').should('be.visible'); pipelineVersionImportModal.fillVersionName('New pipeline version'); pipelineVersionImportModal.findImportPipelineRadio().check(); pipelineVersionImportModal.findPipelineUrlInput().type('https://example.com/pipeline.yaml'); @@ -852,7 +893,6 @@ describe('Pipelines', () => { initIntercepts({}); pipelinesGlobal.visit(projectName); - // Wait for the pipelines table to load pipelinesTable.find(); const pipelineRow = pipelinesTable.getRowById(initialMockPipeline.pipeline_id); pipelineRow.findExpandButton().click(); @@ -861,7 +901,9 @@ describe('Pipelines', () => { .getPipelineVersionRowById(initialMockPipelineVersion.pipeline_version_id) .findPipelineVersionLink() .click(); - verifyRelativeURL( + + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/view`, ); }); @@ -874,7 +916,8 @@ describe('Pipelines', () => { const pipelineRow = pipelinesTable.getRowById(initialMockPipeline.pipeline_id); pipelineRow.findPipelineNameLink(initialMockPipeline.display_name).click(); - verifyRelativeURL( + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/view`, ); }); @@ -956,13 +999,25 @@ describe('Pipelines', () => { }); it('navigate to create run page from pipeline row', () => { - const visitPipelineProjects = () => pipelinesGlobal.visit(projectName); - runCreateRunPageNavTest(visitPipelineProjects); + initIntercepts({}); + pipelinesGlobal.visit(projectName); + + pipelinesTable.find(); + pipelinesTable + .getRowById(initialMockPipeline.pipeline_id) + .findKebabAction('Create run') + .click(); + + cy.url().should( + 'include', + `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/runs/create`, + ); }); it('run and schedule dropdown action should be disabeld when pipeline has no versions', () => { initIntercepts({ hasNoPipelineVersions: true }); pipelinesGlobal.visit(projectName); + pipelinesTable .getRowById(initialMockPipeline.pipeline_id) .findKebabAction('Create schedule') @@ -974,23 +1029,36 @@ describe('Pipelines', () => { }); it('navigates to "Schedule run" page from pipeline row', () => { - const visitPipelineProjects = () => pipelinesGlobal.visit(projectName); - runScheduleRunPageNavTest(visitPipelineProjects); + initIntercepts({}); + pipelinesGlobal.visit(projectName); + + pipelinesTable.find(); + pipelinesTable + .getRowById(initialMockPipeline.pipeline_id) + .findKebabAction('Create schedule') + .click(); + + cy.url().should( + 'include', + `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/schedules/create`, + ); }); it('navigate to create run page from pipeline version row', () => { initIntercepts({}); pipelinesGlobal.visit(projectName); - // Wait for the pipelines table to load pipelinesTable.find(); + const pipelineRow = pipelinesTable.getRowById(initialMockPipeline.pipeline_id); pipelineRow.findExpandButton().click(); pipelineRow .getPipelineVersionRowById(initialMockPipelineVersion.pipeline_version_id) .findKebabAction('Create run') .click(); - verifyRelativeURL( + + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/runs/create`, ); }); @@ -1007,7 +1075,8 @@ describe('Pipelines', () => { .findKebabAction('Create schedule') .click(); - verifyRelativeURL( + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/schedules/create`, ); }); @@ -1016,15 +1085,17 @@ describe('Pipelines', () => { initIntercepts({}); pipelinesGlobal.visit(projectName); - // Wait for the pipelines table to load pipelinesTable.find(); + const pipelineRow = pipelinesTable.getRowById(initialMockPipeline.pipeline_id); pipelineRow.findExpandButton().click(); pipelineRow .getPipelineVersionRowById(initialMockPipelineVersion.pipeline_version_id) .findKebabAction('View runs') .click(); - verifyRelativeURL( + + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/runs`, ); }); @@ -1040,7 +1111,9 @@ describe('Pipelines', () => { .getPipelineVersionRowById(initialMockPipelineVersion.pipeline_version_id) .findKebabAction('View schedules') .click(); - verifyRelativeURL( + + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/schedules`, ); }); @@ -1083,9 +1156,8 @@ describe('Pipelines', () => { pagination.findNextButton().click(); cy.wait('@refreshPipelines').then((interception) => { - expect(interception.request.query).to.eql({ + expect(interception.request.query).to.include({ sort_by: 'created_at desc', - page_size: '10', page_token: 'page-2-token', }); }); @@ -1136,7 +1208,7 @@ type HandlersProps = { nextPageToken?: string | undefined; }; -export const initIntercepts = ({ +const initIntercepts = ({ isEmpty = false, mockPipelines = [initialMockPipeline], hasNoPipelineVersions = false, @@ -1234,63 +1306,3 @@ const createDeletePipelineIntercept = (pipelineId: string) => }, mockSuccessGoogleRpcStatus({}), ); - -export const runCreateRunPageNavTest = (visitPipelineProjects: () => void): void => { - initIntercepts({}); - visitPipelineProjects(); - - // Wait for the pipelines table to load - pipelinesTable.find(); - pipelinesTable.getRowById(initialMockPipeline.pipeline_id).findKebabAction('Create run').click(); - verifyRelativeURL( - `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/runs/create`, - ); -}; - -export const runScheduleRunPageNavTest = (visitPipelineProjects: () => void): void => { - initIntercepts({}); - visitPipelineProjects(); - - pipelinesTable.find(); - pipelinesTable - .getRowById(initialMockPipeline.pipeline_id) - .findKebabAction('Create schedule') - .click(); - - verifyRelativeURL( - `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/schedules/create`, - ); -}; - -export const viewPipelineServerDetailsTest = (visitPipelineProjects: () => void): void => { - initIntercepts({}); - cy.interceptK8s( - { - model: SecretModel, - ns: projectName, - }, - mockSecretK8sResource({ - s3Bucket: 'c2RzZA==', - namespace: projectName, - name: 'aws-connection-test', - }), - ); - visitPipelineProjects(); - viewPipelineDetails(); -}; - -const viewPipelineDetails = ( - accessKey = 'sdsd', - secretKey = 'sdsd', - endpoint = 'https://s3.amazonaws.com', - bucketName = 'test-pipelines-bucket', -) => { - pipelinesGlobal.selectPipelineServerAction('View pipeline server configuration'); - viewPipelineServerModal.shouldHaveAccessKey(accessKey); - viewPipelineServerModal.findPasswordHiddenButton().click(); - viewPipelineServerModal.shouldHaveSecretKey(secretKey); - viewPipelineServerModal.shouldHaveEndPoint(endpoint); - viewPipelineServerModal.shouldHaveBucketName(bucketName); - - viewPipelineServerModal.findCloseButton().click(); -}; diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesList.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesList.cy.ts index 4509fe5e9d..0381c581b3 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesList.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesList.cy.ts @@ -1,5 +1,11 @@ /* eslint-disable camelcase */ -import { buildMockPipelineVersionV2 } from '~/__mocks__'; +import { + buildMockPipelineVersionV2, + buildMockPipelineVersionsV2, + mockProjectK8sResource, + mockRouteK8sResource, + mockSecretK8sResource, +} from '~/__mocks__'; import { mockDataSciencePipelineApplicationK8sResource } from '~/__mocks__/mockDataSciencePipelinesApplicationK8sResource'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mock404Error } from '~/__mocks__/mockK8sStatus'; @@ -9,18 +15,18 @@ import { configurePipelineServerModal, pipelineVersionImportModal, PipelineSort, + pipelinesGlobal, + viewPipelineServerModal, } from '~/__tests__/cypress/cypress/pages/pipelines'; import { pipelinesSection } from '~/__tests__/cypress/cypress/pages/pipelines/pipelinesSection'; import { projectDetails } from '~/__tests__/cypress/cypress/pages/projects'; -import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; -import { DataSciencePipelineApplicationModel } from '~/__tests__/cypress/cypress/utils/models'; -import type { PipelineKFv2 } from '~/concepts/pipelines/kfTypes'; import { - initIntercepts, - runCreateRunPageNavTest, - runScheduleRunPageNavTest, - viewPipelineServerDetailsTest, -} from './pipelines.cy'; + DataSciencePipelineApplicationModel, + ProjectModel, + RouteModel, + SecretModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import type { PipelineKFv2 } from '~/concepts/pipelines/kfTypes'; const projectName = 'test-project-name'; const initialMockPipeline = buildMockPipelineV2({ display_name: 'Test pipeline' }); @@ -44,7 +50,7 @@ const mockPipelines: PipelineKFv2[] = [ describe('PipelinesList', () => { it('should show the configure pipeline server button when the server is not configured', () => { - initIntercepts({ isEmpty: true }); + initIntercepts({ isEmptyProject: true }); cy.interceptK8s( { model: DataSciencePipelineApplicationModel, @@ -63,7 +69,7 @@ describe('PipelinesList', () => { }); it('should verify that clicking on Configure pipeline server button will open a modal', () => { - initIntercepts({ isEmpty: true }); + initIntercepts({ isEmptyProject: true }); projectDetails.visitSection(projectName, 'pipelines-projects'); pipelinesSection.findCreatePipelineButton().should('be.enabled'); pipelinesSection.findCreatePipelineButton().click(); @@ -71,13 +77,32 @@ describe('PipelinesList', () => { }); it('should view pipeline server', () => { - const visitPipelineProjects = () => - projectDetails.visitSection(projectName, 'pipelines-projects'); - viewPipelineServerDetailsTest(visitPipelineProjects); + initIntercepts(); + cy.interceptK8s( + { + model: SecretModel, + ns: projectName, + }, + mockSecretK8sResource({ + s3Bucket: 'c2RzZA==', + namespace: projectName, + name: 'aws-connection-test', + }), + ); + projectDetails.visitSection(projectName, 'pipelines-projects'); + + pipelinesGlobal.selectPipelineServerAction('View pipeline server configuration'); + viewPipelineServerModal.shouldHaveAccessKey('sdsd'); + viewPipelineServerModal.findPasswordHiddenButton().click(); + viewPipelineServerModal.shouldHaveSecretKey('sdsd'); + viewPipelineServerModal.shouldHaveEndPoint('https://s3.amazonaws.com'); + viewPipelineServerModal.shouldHaveBucketName('test-pipelines-bucket'); + + viewPipelineServerModal.findCloseButton().click(); }); it('should disable the upload version button when the list is empty', () => { - initIntercepts({}); + initIntercepts(); cy.interceptK8sList( DataSciencePipelineApplicationModel, mockK8sResourceList([mockDataSciencePipelineApplicationK8sResource({})]), @@ -95,7 +120,6 @@ describe('PipelinesList', () => { ).as('pipelines'); projectDetails.visitSection(projectName, 'pipelines-projects'); - pipelinesSection.findImportPipelineSplitButton().should('be.enabled').click(); cy.wait('@pipelines').then((interception) => { @@ -109,7 +133,7 @@ describe('PipelinesList', () => { }); it('should show the ability to delete the pipeline server kebab option', () => { - initIntercepts({}); + initIntercepts(); projectDetails.visitSection(projectName, 'pipelines-projects'); @@ -118,9 +142,9 @@ describe('PipelinesList', () => { }); it('should show the ability to upload new version when clicking the pipeline server kebab option', () => { - initIntercepts({}); - + initIntercepts(); projectDetails.visitSection(projectName, 'pipelines-projects'); + pipelinesTable.find(); const pipelineRow = pipelinesTable.getRowById(initialMockPipeline.pipeline_id); pipelineRow.findKebabAction('Upload new version').should('be.visible').click(); @@ -128,7 +152,7 @@ describe('PipelinesList', () => { }); it('should navigate to details page when clicking on the version name', () => { - initIntercepts({}); + initIntercepts(); projectDetails.visitSection(projectName, 'pipelines-projects'); pipelinesTable.find(); @@ -138,27 +162,18 @@ describe('PipelinesList', () => { .getPipelineVersionRowById(initialMockPipelineVersion.pipeline_version_id) .findPipelineVersionLink() .click(); - verifyRelativeURL( + + cy.url().should( + 'include', `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/view`, ); }); it('clicking on upload a new pipeline version should show a modal', () => { - initIntercepts({}); - cy.intercept( - { - pathname: `/api/service/pipelines/${projectName}/dspa/apis/v2beta1/pipelines`, - }, - buildMockPipelines([initialMockPipelineVersion]), - ); + initIntercepts(); projectDetails.visitSection(projectName, 'pipelines-projects'); - cy.intercept( - { - pathname: `/api/service/pipelines/${projectName}/dspa/apis/v2beta1/pipelines`, - }, - buildMockPipelines(mockPipelines), - ); + pipelinesTable.find(); pipelinesSection.findImportPipelineSplitButton().click(); pipelinesSection.findUploadVersionButton().click(); pipelineVersionImportModal.shouldBeOpen(); @@ -173,19 +188,117 @@ describe('PipelinesList', () => { }); it('navigates to create run page from pipeline row', () => { - const visitPipelineProjects = () => - projectDetails.visitSection(projectName, 'pipelines-projects'); - runCreateRunPageNavTest(visitPipelineProjects); + initIntercepts(); + projectDetails.visitSection(projectName, 'pipelines-projects'); + + // Wait for the pipelines table to load + pipelinesTable.find(); + pipelinesTable + .getRowById(initialMockPipeline.pipeline_id) + .findKebabAction('Create run') + .click(); + + cy.url().should( + 'include', + `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/runs/create`, + ); }); it('navigates to "Schedule run" page from pipeline row', () => { - const visitPipelineProjects = () => - projectDetails.visitSection(projectName, 'pipelines-projects'); - runScheduleRunPageNavTest(visitPipelineProjects); + initIntercepts(); + projectDetails.visitSection(projectName, 'pipelines-projects'); + + pipelinesTable.find(); + pipelinesTable + .getRowById(initialMockPipeline.pipeline_id) + .findKebabAction('Create schedule') + .click(); + + cy.url().should( + 'include', + `/pipelines/${projectName}/${initialMockPipeline.pipeline_id}/${initialMockPipelineVersion.pipeline_version_id}/schedules/create`, + ); }); }); const pipelineTableSetup = () => { - initIntercepts({ mockPipelines }); + initIntercepts(); projectDetails.visitSection(projectName, 'pipelines-projects'); }; + +export const initIntercepts = ( + { isEmptyProject }: { isEmptyProject?: boolean } = { isEmptyProject: false }, +): void => { + cy.interceptK8sList( + DataSciencePipelineApplicationModel, + mockK8sResourceList( + isEmptyProject + ? [] + : [mockDataSciencePipelineApplicationK8sResource({ namespace: projectName })], + ), + ); + cy.interceptK8s( + DataSciencePipelineApplicationModel, + mockDataSciencePipelineApplicationK8sResource({ + namespace: projectName, + dspaSecretName: 'aws-connection-test', + }), + ); + cy.interceptK8s( + RouteModel, + mockRouteK8sResource({ + notebookName: 'ds-pipeline-dspa', + namespace: projectName, + }), + ); + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([ + mockProjectK8sResource({ k8sName: projectName }), + mockProjectK8sResource({ k8sName: `${projectName}-2`, displayName: 'Test Project 2' }), + ]), + ); + + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines', + { + path: { namespace: projectName, serviceName: 'dspa' }, + }, + buildMockPipelines([initialMockPipeline]), + ).as('getPipelines'); + + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions', + { + path: { + namespace: projectName, + serviceName: 'dspa', + pipelineId: initialMockPipeline.pipeline_id, + }, + }, + buildMockPipelineVersionsV2([initialMockPipelineVersion]), + ); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId', + { + path: { + namespace: projectName, + serviceName: 'dspa', + pipelineId: initialMockPipeline.pipeline_id, + }, + }, + initialMockPipeline, + ); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions/:pipelineVersionId', + { + path: { + namespace: projectName, + serviceName: 'dspa', + pipelineId: initialMockPipeline.pipeline_id, + pipelineVersionId: initialMockPipelineVersion.pipeline_version_id, + }, + }, + initialMockPipelineVersion, + ); +}; diff --git a/frontend/src/concepts/k8s/NameDescriptionField.tsx b/frontend/src/concepts/k8s/NameDescriptionField.tsx index df983f1cd2..3514e74d56 100644 --- a/frontend/src/concepts/k8s/NameDescriptionField.tsx +++ b/frontend/src/concepts/k8s/NameDescriptionField.tsx @@ -23,6 +23,8 @@ type NameDescriptionFieldProps = { showK8sName?: boolean; disableK8sName?: boolean; maxLength?: number; + nameHelperText?: React.ReactNode; + onNameChange?: (value: string) => void; }; const NameDescriptionField: React.FC = ({ @@ -34,6 +36,8 @@ const NameDescriptionField: React.FC = ({ showK8sName, disableK8sName, maxLength, + nameHelperText, + onNameChange, }) => { const autoSelectNameRef = React.useRef(null); @@ -62,14 +66,20 @@ const NameDescriptionField: React.FC = ({ data-testid={nameFieldId} name={nameFieldId} value={data.name} - onChange={(e, name) => setData({ ...data, name })} + onChange={(_e, value) => { + setData({ ...data, name: value }); + onNameChange?.(value); + }} maxLength={maxLength} /> + {maxLength && ( {`Cannot exceed ${maxLength} characters`} )} + + {nameHelperText} {showK8sName && ( diff --git a/frontend/src/concepts/pipelines/apiHooks/useAllPipelineVersions.ts b/frontend/src/concepts/pipelines/apiHooks/useAllPipelineVersions.ts index 060b40bbdf..c84765156b 100644 --- a/frontend/src/concepts/pipelines/apiHooks/useAllPipelineVersions.ts +++ b/frontend/src/concepts/pipelines/apiHooks/useAllPipelineVersions.ts @@ -2,10 +2,41 @@ import React from 'react'; import { PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; import { usePipelinesAPI } from '~/concepts/pipelines/context'; import usePipelineQuery from '~/concepts/pipelines/apiHooks/usePipelineQuery'; -import { PipelineListPaged, PipelineOptions } from '~/concepts/pipelines/types'; +import { + ListPipelineVersions, + PipelineListPaged, + PipelineOptions, + PipelineParams, +} from '~/concepts/pipelines/types'; import { FetchState, NotReadyError } from '~/utilities/useFetchState'; import { useAllPipelines } from '~/concepts/pipelines/apiHooks/usePipelines'; import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; +import { K8sAPIOptions } from '~/k8sTypes'; + +/** + * Recursively fetch each pipeline version page when a next_page_token exists. + */ +async function getAllVersions( + opts: K8sAPIOptions, + pipelineId: string, + params: PipelineParams | undefined, + listPipelineVersions: ListPipelineVersions, +): Promise { + const result = await listPipelineVersions(opts, pipelineId, params); + let allVersions = result.pipeline_versions ?? []; + + if (result.next_page_token) { + const nextVersions = await getAllVersions( + opts, + pipelineId, + { pageSize: result.pipeline_versions?.length, pageToken: result.next_page_token }, + listPipelineVersions, + ); + allVersions = allVersions.concat(nextVersions); + } + + return allVersions; +} /** * Fetch all pipelines, then use those pipeline IDs to accumulate a list of pipeline versions. @@ -26,15 +57,15 @@ export const useAllPipelineVersions = ( } const pipelineVersionRequests = pipelineIds.map((pipelineId) => - api.listPipelineVersions(opts, pipelineId, params), + getAllVersions(opts, pipelineId, params, api.listPipelineVersions), ); const results = await Promise.all(pipelineVersionRequests); return results.reduce( - (acc: { total_size: number; items: PipelineVersionKFv2[] }, result) => { + (acc: { total_size: number; items: PipelineVersionKFv2[] }, versions) => { // eslint-disable-next-line camelcase - acc.total_size += result.total_size || 0; - acc.items = acc.items.concat(result.pipeline_versions || []); + acc.total_size += versions.length || 0; + acc.items = acc.items.concat(versions); return acc; }, diff --git a/frontend/src/concepts/pipelines/apiHooks/usePipelines.ts b/frontend/src/concepts/pipelines/apiHooks/usePipelines.ts index c9446956c2..1afa6f1653 100644 --- a/frontend/src/concepts/pipelines/apiHooks/usePipelines.ts +++ b/frontend/src/concepts/pipelines/apiHooks/usePipelines.ts @@ -60,7 +60,7 @@ async function getAllPipelines( if (result.next_page_token) { const nextPipelines = await getAllPipelines( opts, - { pageToken: result.next_page_token }, + { ...params, pageToken: result.next_page_token }, listPipelines, ); allPipelines = allPipelines.concat(nextPipelines); diff --git a/frontend/src/concepts/pipelines/content/DuplicateNameHelperText.tsx b/frontend/src/concepts/pipelines/content/DuplicateNameHelperText.tsx new file mode 100644 index 0000000000..727d3c0df8 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/DuplicateNameHelperText.tsx @@ -0,0 +1,31 @@ +import { HelperText, HelperTextItem, HelperTextItemProps, Icon } from '@patternfly/react-core'; +import { InfoCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; + +interface DuplicateNameHelperTextProps { + name: string; + isError?: boolean; +} + +export const DuplicateNameHelperText: React.FC = ({ + name, + isError, +}) => { + const helperTextItemProps: HelperTextItemProps = isError + ? { variant: 'error', hasIcon: true } + : { + icon: ( + + + + ), + }; + + return ( + + + {name} already exists. Try a different name. + + + ); +}; diff --git a/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx b/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx index 2ca75e042d..d33e0eb9a0 100644 --- a/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx +++ b/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx @@ -5,10 +5,19 @@ import { RunFormData, RunTypeOption } from '~/concepts/pipelines/content/createR import { ValueOf } from '~/typeHelpers'; import { ParamsSection } from '~/concepts/pipelines/content/createRun/contentSections/ParamsSection'; import RunTypeSectionScheduled from '~/concepts/pipelines/content/createRun/contentSections/RunTypeSectionScheduled'; -import { PipelineVersionKFv2, RuntimeConfigParameters } from '~/concepts/pipelines/kfTypes'; +import { + PipelineRecurringRunKFv2, + PipelineRunKFv2, + PipelineVersionKFv2, + RuntimeConfigParameters, +} from '~/concepts/pipelines/kfTypes'; import ProjectAndExperimentSection from '~/concepts/pipelines/content/createRun/contentSections/ProjectAndExperimentSection'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import { useLatestPipelineVersion } from '~/concepts/pipelines/apiHooks/useLatestPipelineVersion'; +import { getNameEqualsFilter } from '~/concepts/pipelines/utils'; +import { DuplicateNameHelperText } from '~/concepts/pipelines/content/DuplicateNameHelperText'; +import { usePipelinesAPI } from '~/concepts/pipelines/context'; +import useDebounceCallback from '~/utilities/useDebounceCallback'; import PipelineSection from './contentSections/PipelineSection'; import { RunTypeSection } from './contentSections/RunTypeSection'; import { CreateRunPageSections, RUN_NAME_CHARACTER_LIMIT, runPageSectionTitles } from './const'; @@ -21,12 +30,42 @@ type RunFormProps = { }; const RunForm: React.FC = ({ data, onValueChange, isCloned }) => { + const { api } = usePipelinesAPI(); const [latestVersion] = useLatestPipelineVersion(data.pipeline?.pipeline_id); // Use this state to avoid the pipeline version being set as the latest version at the initial load const [initialLoadedState, setInitialLoadedState] = React.useState(true); const selectedVersion = data.version || latestVersion; const paramsRef = React.useRef(data.params); const isSchedule = data.runType.type === RunTypeOption.SCHEDULED; + const { name } = data.nameDesc; + const [hasDuplicateName, setHasDuplicateName] = React.useState(false); + + const checkForDuplicateName = useDebounceCallback( + React.useCallback( + async (value: string) => { + if (value) { + let duplicateRuns: PipelineRunKFv2[] | PipelineRecurringRunKFv2[] | undefined = []; + + if (isSchedule) { + const { recurringRuns } = await api.listPipelineRecurringRuns( + {}, + getNameEqualsFilter(value), + ); + duplicateRuns = recurringRuns; + } else { + const { runs } = await api.listPipelineActiveRuns({}, getNameEqualsFilter(value)); + duplicateRuns = runs; + } + + if (duplicateRuns?.length) { + setHasDuplicateName(true); + } + } + }, + [api, isSchedule], + ), + 500, + ); const updateInputParams = React.useCallback( (version: PipelineVersionKFv2 | undefined) => @@ -75,6 +114,11 @@ const RunForm: React.FC = ({ data, onValueChange, isCloned }) => { data={data.nameDesc} setData={(nameDesc) => onValueChange('nameDesc', nameDesc)} maxLength={RUN_NAME_CHARACTER_LIMIT} + onNameChange={(value) => { + setHasDuplicateName(false); + checkForDuplicateName(value); + }} + nameHelperText={hasDuplicateName ? : undefined} /> {isSchedule && data.runType.type === RunTypeOption.SCHEDULED && ( diff --git a/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx b/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx index f366553447..4e155bbdb5 100644 --- a/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx +++ b/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx @@ -14,6 +14,9 @@ import { usePipelinesAPI } from '~/concepts/pipelines/context'; import { usePipelineImportModalData } from '~/concepts/pipelines/content/import/useImportModalData'; import { PipelineKFv2 } from '~/concepts/pipelines/kfTypes'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { DuplicateNameHelperText } from '~/concepts/pipelines/content/DuplicateNameHelperText'; +import { getNameEqualsFilter } from '~/concepts/pipelines/utils'; +import useDebounceCallback from '~/utilities/useDebounceCallback'; import PipelineUploadRadio from './PipelineUploadRadio'; import { PipelineUploadOption } from './utils'; @@ -28,6 +31,7 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo const [error, setError] = React.useState(); const [{ name, description, fileContents, pipelineUrl, uploadOption }, setData, resetData] = usePipelineImportModalData(); + const [hasDuplicateName, setHasDuplicateName] = React.useState(false); const isImportButtonDisabled = !apiAvailable || @@ -42,6 +46,25 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo resetData(); }; + const checkForDuplicateName = useDebounceCallback( + React.useCallback( + async (value: string) => { + if (value) { + const { pipelines: duplicatePipelines } = await api.listPipelines( + {}, + getNameEqualsFilter(value), + ); + + if (duplicatePipelines?.length) { + setHasDuplicateName(true); + } + } + }, + [api], + ), + 500, + ); + const onSubmit = () => { setImporting(true); setError(undefined); @@ -121,8 +144,14 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo data-testid="pipeline-name" name="pipeline-name" value={name} - onChange={(e, value) => setData('name', value)} + onChange={(_e, value) => { + setHasDuplicateName(false); + setData('name', value); + checkForDuplicateName(value); + }} /> + + {hasDuplicateName && } @@ -134,7 +163,7 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo data-testid="pipeline-description" name="pipeline-description" value={description} - onChange={(e, value) => setData('description', value)} + onChange={(_e, value) => setData('description', value)} /> diff --git a/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx b/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx index fc361f54a4..5d4628dfb4 100644 --- a/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx +++ b/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx @@ -15,6 +15,9 @@ import { usePipelineVersionImportModalData } from '~/concepts/pipelines/content/ import { PipelineKFv2, PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; import PipelineSelector from '~/concepts/pipelines/content/pipelineSelector/PipelineSelector'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { DuplicateNameHelperText } from '~/concepts/pipelines/content/DuplicateNameHelperText'; +import { getNameEqualsFilter } from '~/concepts/pipelines/utils'; +import useDebounceCallback from '~/utilities/useDebounceCallback'; import { PipelineUploadOption, generatePipelineVersionName } from './utils'; import PipelineUploadRadio from './PipelineUploadRadio'; @@ -30,6 +33,7 @@ const PipelineVersionImportModal: React.FC = ({ const { project, api, apiAvailable } = usePipelinesAPI(); const [importing, setImporting] = React.useState(false); const [error, setError] = React.useState(); + const [hasDuplicateName, setHasDuplicateName] = React.useState(false); const [ { name, description, pipeline, fileContents, uploadOption, pipelineUrl }, setData, @@ -56,6 +60,26 @@ const PipelineVersionImportModal: React.FC = ({ resetData(); }; + const checkForDuplicateName = useDebounceCallback( + React.useCallback( + async (value: string) => { + if (pipeline?.pipeline_id && value) { + const { pipeline_versions: duplicateVersions } = await api.listPipelineVersions( + {}, + pipeline.pipeline_id, + getNameEqualsFilter(value), + ); + + if (duplicateVersions?.length) { + setHasDuplicateName(true); + } + } + }, + [api, pipeline?.pipeline_id], + ), + 500, + ); + const onSubmit = () => { setImporting(true); setError(undefined); @@ -136,8 +160,14 @@ const PipelineVersionImportModal: React.FC = ({ id="pipeline-version-name" name="pipeline-version-name" value={name} - onChange={(e, value) => setData('name', value)} + onChange={(_e, value) => { + setHasDuplicateName(false); + setData('name', value); + checkForDuplicateName(value); + }} /> + + {hasDuplicateName && } @@ -149,7 +179,7 @@ const PipelineVersionImportModal: React.FC = ({ data-testid="pipeline-version-description" name="pipeline-version-description" value={description} - onChange={(e, value) => setData('description', value)} + onChange={(_e, value) => setData('description', value)} /> diff --git a/frontend/src/concepts/pipelines/utils.ts b/frontend/src/concepts/pipelines/utils.ts index eba05fe29c..d9f6d527d4 100644 --- a/frontend/src/concepts/pipelines/utils.ts +++ b/frontend/src/concepts/pipelines/utils.ts @@ -5,7 +5,8 @@ import { } from '~/concepts/pipelines/content/configurePipelinesServer/const'; import { ELYRA_SECRET_NAME } from '~/concepts/pipelines/elyra/const'; import { allSettledPromises } from '~/utilities/allSettledPromises'; -import { PipelineRecurringRunKFv2, PipelineRunKFv2 } from './kfTypes'; +import { PipelineRecurringRunKFv2, PipelineRunKFv2, PipelinesFilterOp } from './kfTypes'; +import { PipelineParams } from './types'; export const deleteServer = async (namespace: string, crName: string): Promise => { const dspa = await getPipelinesCR(namespace, crName); @@ -33,3 +34,16 @@ export const isGeneratedDSPAExternalStorageSecret = (name: string): boolean => export const isRunSchedule = ( resource: PipelineRunKFv2 | PipelineRecurringRunKFv2, ): resource is PipelineRecurringRunKFv2 => 'trigger' in resource; + +export const getNameEqualsFilter = (name: string): Pick => ({ + filter: { + predicates: [ + { + key: 'name', + operation: PipelinesFilterOp.EQUALS, + // eslint-disable-next-line camelcase + string_value: name, + }, + ], + }, +});