Commit 3c38adc1 authored by Frédéric Caplette's avatar Frédéric Caplette

Merge branch '21425-add-polling-to-mini-pipeline-graph' into 'master'

Add polling to commit box mini pipeline graph

See merge request gitlab-org/gitlab!83890
parents dfd88084 04c2c5a6
......@@ -3,11 +3,20 @@ import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import { formatStages } from '../utils';
import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql';
import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql';
import { PIPELINE_STAGES_POLL_INTERVAL } from '../constants';
export default {
i18n: {
linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'),
stageConversionError: __('There was a problem handling the pipeline data.'),
stagesFetchError: __('There was a problem fetching the pipeline stages.'),
},
components: {
GlLoadingIcon,
......@@ -22,6 +31,9 @@ export default {
iid: {
default: '',
},
graphqlResourceEtag: {
default: '',
},
},
props: {
stages: {
......@@ -48,10 +60,31 @@ export default {
createFlash({ message: this.$options.i18n.linkedPipelinesFetchError });
},
},
pipelineStages: {
context() {
return getQueryHeaders(this.graphqlResourceEtag);
},
query: getPipelineStagesQuery,
pollInterval: PIPELINE_STAGES_POLL_INTERVAL,
variables() {
return {
fullPath: this.fullPath,
iid: this.iid,
};
},
update({ project }) {
return project?.pipeline?.stages?.nodes || [];
},
error() {
createFlash({ message: this.$options.i18n.stagesFetchError });
},
},
},
data() {
return {
formattedStages: [],
pipeline: null,
pipelineStages: [],
};
},
computed: {
......@@ -65,6 +98,25 @@ export default {
return this.pipeline?.upstream;
},
},
watch: {
pipelineStages() {
// pipelineStages are from GraphQL
// stages are from REST
// we do this to use dropdown_path for fetching jobs on stage click
try {
this.formattedStages = formatStages(this.pipelineStages, this.stages);
} catch (error) {
createFlash({
message: this.$options.i18n.stageConversionError,
captureError: true,
error,
});
}
},
},
mounted() {
toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
},
};
</script>
......@@ -79,7 +131,7 @@ export default {
/>
<pipeline-mini-graph
:stages="stages"
:stages="formattedStages"
class="gl-display-inline"
data-testid="commit-box-mini-graph"
/>
......
export const PIPELINE_STAGES_POLL_INTERVAL = 10000;
query getPipelineStages($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
id
pipeline(iid: $iid) {
id
stages {
nodes {
id
name
detailedStatus {
id
icon
group
}
}
}
}
}
}
......@@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { useGet: true }),
});
export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => {
......@@ -15,7 +15,7 @@ export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipelin
return;
}
const { stages, fullPath, iid } = el.dataset;
const { stages, fullPath, iid, graphqlResourceEtag } = el.dataset;
// Some commits have no pipeline, code splitting to load the pipeline optionally
const { default: CommitBoxPipelineMiniGraph } = await import(
......@@ -30,6 +30,7 @@ export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipelin
fullPath,
iid,
dataMethod: 'graphql',
graphqlResourceEtag,
},
render(createElement) {
return createElement(CommitBoxPipelineMiniGraph, {
......
export const formatStages = (graphQLStages = [], restStages = []) => {
if (graphQLStages.length !== restStages.length) {
throw new Error('Rest stages and graphQl stages must be the same length');
}
return graphQLStages.map((stage, index) => {
return {
name: stage.name,
status: stage.detailedStatus,
dropdown_path: restStages[index]?.dropdown_path || '',
title: restStages[index].title || '',
};
});
};
......@@ -57,7 +57,7 @@
#{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), @last_pipeline.stages_count) }
.mr-widget-pipeline-graph
.stage-cell
.js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid } }
.js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } }
- if @last_pipeline.duration
in
= time_interval_in_words @last_pipeline.duration
......
......@@ -7,12 +7,16 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue';
import { PIPELINE_STAGES_POLL_INTERVAL } from '~/projects/commit_box/info/constants';
import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql';
import * as graphQlUtils from '~/pipelines/components/graph/utils';
import {
mockDownstreamQueryResponse,
mockUpstreamQueryResponse,
mockUpstreamDownstreamQueryResponse,
mockStages,
mockPipelineStagesQueryResponse,
} from '../mock_data';
const fullPath = 'gitlab-org/gitlab';
......@@ -30,14 +34,22 @@ describe('Commit box pipeline mini graph', () => {
.fn()
.mockResolvedValue(mockUpstreamDownstreamQueryResponse);
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const stagesHandler = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findMiniGraph = () => wrapper.findByTestId('commit-box-mini-graph');
const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream');
const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream');
const createMockApolloProvider = (handler) => {
const requestHandlers = [[getLinkedPipelinesQuery, handler]];
const advanceToNextFetch = () => {
jest.advanceTimersByTime(PIPELINE_STAGES_POLL_INTERVAL);
};
const createMockApolloProvider = (handler = downstreamHandler) => {
const requestHandlers = [
[getLinkedPipelinesQuery, handler],
[getPipelineStagesQuery, stagesHandler],
];
return createMockApollo(requestHandlers);
};
......@@ -52,6 +64,7 @@ describe('Commit box pipeline mini graph', () => {
fullPath,
iid,
dataMethod: 'graphql',
graphqlResourceEtag: '/api/graphql:pipelines/id/320',
},
apolloProvider: createMockApolloProvider(handler),
}),
......@@ -64,15 +77,16 @@ describe('Commit box pipeline mini graph', () => {
describe('loading state', () => {
it('should display loading state when loading', () => {
createComponent(downstreamHandler);
createComponent();
expect(findLoadingIcon().exists()).toBe(true);
expect(findMiniGraph().exists()).toBe(false);
});
});
describe('loaded state', () => {
it('should not display loading state after the query is resolved', async () => {
createComponent(downstreamHandler);
createComponent();
await waitForPromises();
......@@ -81,7 +95,7 @@ describe('Commit box pipeline mini graph', () => {
});
it('should pass the pipeline path prop for the counter badge', async () => {
createComponent(downstreamHandler);
createComponent();
await waitForPromises();
......@@ -105,6 +119,28 @@ describe('Commit box pipeline mini graph', () => {
expect(findUpstream().exists()).toBe(upstreamRenders);
});
});
it('formatted stages should be passed to the pipeline mini graph', async () => {
const stage = mockStages[0];
const expectedStages = [
{
name: stage.name,
status: {
id: stage.status.id,
icon: stage.status.icon,
group: stage.status.group,
},
dropdown_path: stage.dropdown_path,
title: stage.title,
},
];
createComponent();
await waitForPromises();
expect(findMiniGraph().props('stages')).toEqual(expectedStages);
});
});
describe('error state', () => {
......@@ -118,4 +154,44 @@ describe('Commit box pipeline mini graph', () => {
});
});
});
describe('polling', () => {
it('polling interval is set for pipeline stages', () => {
createComponent();
const expectedInterval = wrapper.vm.$apollo.queries.pipelineStages.options.pollInterval;
expect(expectedInterval).toBe(PIPELINE_STAGES_POLL_INTERVAL);
});
it('polls for stages', async () => {
createComponent();
await waitForPromises();
expect(stagesHandler).toHaveBeenCalledTimes(1);
advanceToNextFetch();
await waitForPromises();
expect(stagesHandler).toHaveBeenCalledTimes(2);
advanceToNextFetch();
await waitForPromises();
expect(stagesHandler).toHaveBeenCalledTimes(3);
});
it('toggles pipelineStages polling with visibility check', async () => {
jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility');
createComponent();
await waitForPromises();
expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
wrapper.vm.$apollo.queries.pipelineStages,
);
});
});
});
......@@ -104,133 +104,50 @@ export const mockUpstreamDownstreamQueryResponse = {
},
};
export const mockStages = [
{
id: 'stage-1',
name: 'build',
title: 'build: passed',
status: {
id: 'status-1',
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
tooltip: 'passed',
has_details: true,
details_path: '/root/ci-project/-/pipelines/611#build',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
},
path: '/root/ci-project/-/pipelines/611#build',
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=build',
},
{
id: 'stage-2',
name: 'test',
title: 'test: passed',
status: {
id: 'status-2',
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
tooltip: 'passed',
has_details: true,
details_path: '/root/ci-project/-/pipelines/611#test',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
},
path: '/root/ci-project/-/pipelines/611#test',
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=test',
},
{
id: 'stage-3',
name: 'test_two',
title: 'test_two: passed',
status: {
id: 'status-3',
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
tooltip: 'passed',
has_details: true,
details_path: '/root/ci-project/-/pipelines/611#test_two',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
},
path: '/root/ci-project/-/pipelines/611#test_two',
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=test_two',
},
{
id: 'stage-4',
name: 'manual',
title: 'manual: skipped',
status: {
id: 'status-4',
icon: 'status_skipped',
text: 'skipped',
label: 'skipped',
group: 'skipped',
tooltip: 'skipped',
has_details: true,
details_path: '/root/ci-project/-/pipelines/611#manual',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
action: {
id: 'action-4',
icon: 'play',
title: 'Play all manual',
path: '/root/ci-project/-/pipelines/611/stages/manual/play_manual',
method: 'post',
button_title: 'Play all manual',
export const mockPipelineStagesQueryResponse = {
data: {
project: {
id: 'gid://gitlab/Project/20',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/320',
stages: {
nodes: [
{
__typename: 'CiStage',
id: 'gid://gitlab/Ci::Stage/409',
name: 'build',
detailedStatus: {
id: 'success-409-409',
group: 'success',
icon: 'status_success',
__typename: 'DetailedStatus',
},
},
],
},
},
},
path: '/root/ci-project/-/pipelines/611#manual',
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=manual',
},
{
id: 'stage-5',
name: 'deploy',
title: 'deploy: passed',
status: {
id: 'status-5',
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
tooltip: 'passed',
has_details: true,
details_path: '/root/ci-project/-/pipelines/611#deploy',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
},
path: '/root/ci-project/-/pipelines/611#deploy',
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=deploy',
},
};
export const mockStages = [
{
id: 'stage-6',
name: 'qa',
title: 'qa: passed',
name: 'build',
title: 'build: passed',
status: {
id: 'status-6',
id: 'success-409-409',
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
tooltip: 'passed',
has_details: true,
details_path: '/root/ci-project/-/pipelines/611#qa',
details_path: '/root/ci-project/-/pipelines/318#build',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
},
path: '/root/ci-project/-/pipelines/611#qa',
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa',
path: '/root/ci-project/-/pipelines/318#build',
dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=build',
},
];
......@@ -38011,12 +38011,18 @@ msgstr ""
msgid "There was a problem fetching the keep latest artifacts setting."
msgstr ""
msgid "There was a problem fetching the pipeline stages."
msgstr ""
msgid "There was a problem fetching the projects"
msgstr ""
msgid "There was a problem fetching users."
msgstr ""
msgid "There was a problem handling the pipeline data."
msgstr ""
msgid "There was a problem sending the confirmation email"
msgstr ""
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue';
import { mockStages } from './mock_data';
import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql';
import { mockPipelineStagesQueryResponse, mockStages } from './mock_data';
jest.mock('~/flash');
Vue.use(VueApollo);
describe('Commit box pipeline mini graph', () => {
let wrapper;
......@@ -10,34 +21,36 @@ describe('Commit box pipeline mini graph', () => {
const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream');
const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream');
const createComponent = () => {
const stagesHandler = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse);
const createComponent = ({ props = {} } = {}) => {
const handlers = [
[getLinkedPipelinesQuery, {}],
[getPipelineStagesQuery, stagesHandler],
];
wrapper = extendedWrapper(
shallowMount(CommitBoxPipelineMiniGraph, {
propsData: {
stages: mockStages,
...props,
},
mocks: {
$apollo: {
queries: {
pipeline: {
loading: false,
},
},
},
},
apolloProvider: createMockApollo(handlers),
}),
);
};
beforeEach(() => {
createComponent();
});
return waitForPromises();
};
afterEach(() => {
wrapper.destroy();
});
describe('linked pipelines', () => {
beforeEach(async () => {
await createComponent();
});
it('should display the mini pipeine graph', () => {
expect(findMiniGraph().exists()).toBe(true);
});
......@@ -47,4 +60,18 @@ describe('Commit box pipeline mini graph', () => {
expect(findDownstream().exists()).toBe(false);
});
});
describe('when data is mismatched', () => {
beforeEach(async () => {
await createComponent({ props: { stages: [] } });
});
it('calls create flash with expected arguments', () => {
expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem handling the pipeline data.',
captureError: true,
error: new Error('Rest stages and graphQl stages must be the same length'),
});
});
});
});
......@@ -115,3 +115,29 @@ export const mockStages = [
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa',
},
];
export const mockPipelineStagesQueryResponse = {
data: {
project: {
id: 'gid://gitlab/Project/20',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/320',
stages: {
nodes: [
{
__typename: 'CiStage',
id: 'gid://gitlab/Ci::Stage/409',
name: 'build',
detailedStatus: {
id: 'success-409-409',
group: 'success',
icon: 'status_success',
__typename: 'DetailedStatus',
},
},
],
},
},
},
},
};
import { formatStages } from '~/projects/commit_box/info/utils';
const graphqlStage = [
{
__typename: 'CiStage',
name: 'deploy',
detailedStatus: {
__typename: 'DetailedStatus',
icon: 'status_success',
group: 'success',
id: 'success-409-409',
},
},
];
const restStage = [
{
name: 'deploy',
title: 'deploy: passed',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
tooltip: 'passed',
has_details: true,
details_path: '/root/ci-project/-/pipelines/318#deploy',
illustration: null,
favicon:
'/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
},
path: '/root/ci-project/-/pipelines/318#deploy',
dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy',
},
];
describe('Utils', () => {
it('combines REST and GraphQL stages correctly for component', () => {
expect(formatStages(graphqlStage, restStage)).toEqual([
{
dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy',
name: 'deploy',
status: {
__typename: 'DetailedStatus',
group: 'success',
icon: 'status_success',
id: 'success-409-409',
},
title: 'deploy: passed',
},
]);
});
it('throws an error if arrays are not the same length', () => {
expect(() => {
formatStages(graphqlStage, []);
}).toThrow('Rest stages and graphQl stages must be the same length');
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment