diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 5310165bc13312f875d12a39df42ba6db3ca8032..b2cef7c37b98b76305de0c5f6637110cb8956b71 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -1,8 +1,8 @@ <script> -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; @@ -34,6 +34,7 @@ export default { components: { GlAlert, GlLoadingIcon, + GlSprintf, GraphViewSelector, LocalStorageSync, PipelineGraph, @@ -62,6 +63,7 @@ export default { pipeline: null, skipRetryModal: false, showAlert: false, + showJobCountWarning: false, showLinks: false, }; }, @@ -166,7 +168,12 @@ export default { }, ); }, - result({ error }) { + result({ data, error }) { + const stages = data?.project?.pipeline?.stages?.nodes || []; + + this.showJobCountWarning = stages.some((stage) => { + return stage.groups.nodes.length >= 100; + }); /* If there is a successful load after a failure, clear the failure notification to avoid confusion. @@ -273,14 +280,38 @@ export default { this.currentViewType = type; }, }, + i18n: { + jobLimitWarning: { + title: s__('Pipeline|Only the first 100 jobs per stage are displayed'), + desc: s__('Pipeline|To see the remaining jobs, go to the %{boldStart}Jobs%{boldEnd} tab.'), + }, + }, viewTypeKey: VIEW_TYPE_KEY, }; </script> <template> <div> - <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> + <gl-alert + v-if="showAlert" + :variant="alert.variant" + data-testid="error-alert" + @dismiss="hideAlert" + > {{ alert.text }} </gl-alert> + <gl-alert + v-if="showJobCountWarning" + variant="warning" + :dismissible="false" + :title="$options.i18n.jobLimitWarning.title" + data-testid="job-count-warning" + > + <gl-sprintf :message="$options.i18n.jobLimitWarning.desc"> + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </gl-alert> <local-storage-sync :storage-key="$options.viewTypeKey" :value="currentViewType" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8027563f4511a3f3f6d1141876cc4226bf1e80c7..e175d64ddf866f5e2c964caf75a828b2f805dc3d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -33977,6 +33977,9 @@ msgstr "" msgid "Pipeline|No failed jobs in this pipeline 🎉" msgstr "" +msgid "Pipeline|Only the first 100 jobs per stage are displayed" +msgstr "" + msgid "Pipeline|Passed" msgstr "" @@ -34061,6 +34064,9 @@ msgstr "" msgid "Pipeline|To run a merge request pipeline, the jobs in the CI/CD configuration file %{linkStart}must be configured%{linkEnd} to run in merge request pipelines." msgstr "" +msgid "Pipeline|To see the remaining jobs, go to the %{boldStart}Jobs%{boldEnd} tab." +msgstr "" + msgid "Pipeline|Trigger author" msgstr "" diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 9599b5e6b7b391ce5405499e5e24609e5dfb20c4..7b59d82ae6f8e991cc538153764bd580411e8195 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -34,7 +34,11 @@ import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_head import * as sentryUtils from '~/pipelines/utils'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { mockRunningPipelineHeaderData } from '../mock_data'; -import { mapCallouts, mockCalloutsResponse } from './mock_data'; +import { + mapCallouts, + mockCalloutsResponse, + mockPipelineResponseWithTooManyJobs, +} from './mock_data'; const defaultProvide = { graphqlResourceEtag: 'frog/amphibirama/etag/', @@ -49,7 +53,10 @@ describe('Pipeline graph wrapper', () => { let wrapper; let requestHandlers; - const findAlert = () => wrapper.findComponent(GlAlert); + let pipelineDetailsHandler; + + const findAlert = () => wrapper.findByTestId('error-alert'); + const findJobCountWarning = () => wrapper.findByTestId('job-count-warning'); const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLinksLayer = () => wrapper.findComponent(LinksLayer); @@ -83,7 +90,6 @@ describe('Pipeline graph wrapper', () => { const createComponentWithApollo = ({ calloutsList = [], data = {}, - getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), mountFn = shallowMountExtended, provide = {}, } = {}) => { @@ -92,7 +98,7 @@ describe('Pipeline graph wrapper', () => { requestHandlers = { getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)), getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData), - getPipelineDetailsHandler, + getPipelineDetailsHandler: pipelineDetailsHandler, }; const handlers = [ @@ -105,24 +111,29 @@ describe('Pipeline graph wrapper', () => { createComponent({ apolloProvider, data, provide, mountFn }); }; + beforeEach(() => { + pipelineDetailsHandler = jest.fn(); + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponse); + }); + describe('when data is loading', () => { - it('displays the loading icon', () => { + beforeEach(() => { createComponentWithApollo(); + }); + + it('displays the loading icon', () => { expect(findLoadingIcon().exists()).toBe(true); }); it('does not display the alert', () => { - createComponentWithApollo(); expect(findAlert().exists()).toBe(false); }); it('does not display the graph', () => { - createComponentWithApollo(); expect(findGraph().exists()).toBe(false); }); it('skips querying headerPipeline', () => { - createComponentWithApollo(); expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true); }); }); @@ -153,11 +164,25 @@ describe('Pipeline graph wrapper', () => { }); }); + describe('when a stage has 100 jobs or more', () => { + beforeEach(async () => { + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponseWithTooManyJobs); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('show a warning alert', () => { + expect(findJobCountWarning().exists()).toBe(true); + expect(findJobCountWarning().props().title).toBe( + 'Only the first 100 jobs per stage are displayed', + ); + }); + }); + describe('when there is an error', () => { beforeEach(async () => { - createComponentWithApollo({ - getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), - }); + pipelineDetailsHandler.mockRejectedValue(new Error('GraphQL error')); + createComponentWithApollo(); await waitForPromises(); }); @@ -270,13 +295,12 @@ describe('Pipeline graph wrapper', () => { errors: [{ message: 'timeout' }], }; - const failSucceedFail = jest - .fn() + pipelineDetailsHandler .mockResolvedValueOnce(errorData) .mockResolvedValueOnce(mockPipelineResponse) .mockResolvedValueOnce(errorData); - createComponentWithApollo({ getPipelineDetailsHandler: failSucceedFail }); + createComponentWithApollo(); await waitForPromises(); }); @@ -438,9 +462,9 @@ describe('Pipeline graph wrapper', () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); createComponentWithApollo({ mountFn: mountExtended, - getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); await waitForPromises(); @@ -460,9 +484,9 @@ describe('Pipeline graph wrapper', () => { const nonNeedsResponse = { ...mockPipelineResponse }; nonNeedsResponse.data.project.pipeline.usesNeeds = false; + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); createComponentWithApollo({ mountFn: mountExtended, - getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); jest.runOnlyPendingTimers(); diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index b012e7f66e1099667fb7da9aa235ac49ce2c773c..8d06d6931ed4c20a508f810f811c00d051b353d2 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,3 +1,4 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; import { BUILD_KIND, @@ -5,6 +6,14 @@ import { RETRY_ACTION_TITLE, } from '~/pipelines/components/graph/constants'; +// We mock this instead of using fixtures for performance reason. +const mockPipelineResponseCopy = JSON.parse(JSON.stringify(mockPipelineResponse)); +const groups = new Array(100).fill({ + ...mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes[0], +}); +mockPipelineResponseCopy.data.project.pipeline.stages.nodes[0].groups.nodes = groups; +export const mockPipelineResponseWithTooManyJobs = mockPipelineResponseCopy; + export const downstream = { nodes: [ {