Commit 04c2c5a6 authored by PaytonBurdette's avatar PaytonBurdette

Add polling to commit box graph

Add graphql polling to the mini
pipeline graph in the commit box.
This fixes the two graphs being out
of sync.

Changelog: fixed
parent cacf9078
......@@ -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',
},
];
......@@ -37999,12 +37999,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