Commit 6c5b6423 authored by Mireya Andres's avatar Mireya Andres Committed by Kushal Pandya

Show status of pipelines triggered by commits in the pipeline editor

This adds the Pipeline Status component in the pipeline editor. When the
user commits changes in their CI file through the pipeline editor, the
component will show the status of the triggered pipeline.
parent 868140ae
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineStatus from './pipeline_status.vue';
import ValidationSegment from './validation_segment.vue';
const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100'];
const pipelineStatusClasses = [
...baseClasses,
'gl-border-1',
'gl-border-b-0!',
'gl-rounded-top-base',
];
const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base'];
const validationSegmentWithPipelineStatusClasses = [
...baseClasses,
'gl-border-1',
'gl-rounded-bottom-left-base',
'gl-rounded-bottom-right-base',
];
export default {
validationSegmentClasses:
'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base',
pipelineStatusClasses,
validationSegmentClasses,
validationSegmentWithPipelineStatusClasses,
components: {
PipelineStatus,
ValidationSegment,
},
mixins: [glFeatureFlagsMixin()],
props: {
ciConfigData: {
type: Object,
......@@ -17,12 +40,25 @@ export default {
required: true,
},
},
computed: {
showPipelineStatus() {
return this.glFeatures.pipelineStatusForPipelineEditor;
},
// make sure corners are rounded correctly depending on if
// pipeline status is rendered
validationStyling() {
return this.showPipelineStatus
? this.$options.validationSegmentWithPipelineStatusClasses
: this.$options.validationSegmentClasses;
},
},
};
</script>
<template>
<div class="gl-mb-5">
<pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
<validation-segment
:class="$options.validationSegmentClasses"
:class="validationStyling"
:loading="isCiConfigDataLoading"
:ci-config="ciConfigData"
/>
......
<script>
import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const POLL_INTERVAL = 10000;
export const i18n = {
fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'),
fetchLoading: s__('Pipeline|Checking pipeline status'),
pipelineInfo: s__(
`Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
),
};
export default {
i18n,
components: {
CiIcon,
GlIcon,
GlLink,
GlLoadingIcon,
GlSprintf,
},
inject: ['projectFullPath'],
apollo: {
commitSha: {
query: getCommitSha,
},
pipeline: {
query: getPipelineQuery,
variables() {
return {
fullPath: this.projectFullPath,
sha: this.commitSha,
};
},
update: (data) => {
const { id, commitPath = '', shortSha = '', detailedStatus = {} } =
data.project?.pipeline || {};
return {
id,
commitPath,
shortSha,
detailedStatus,
};
},
error() {
this.hasError = true;
},
pollInterval: POLL_INTERVAL,
},
},
data() {
return {
hasError: false,
};
},
computed: {
hasPipelineData() {
return Boolean(this.$apollo.queries.pipeline?.id);
},
isQueryLoading() {
return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
},
status() {
return this.pipeline.detailedStatus;
},
pipelineId() {
return getIdFromGraphQLId(this.pipeline.id);
},
},
};
</script>
<template>
<div class="gl-white-space-nowrap gl-max-w-full">
<template v-if="isQueryLoading">
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
<span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
</template>
<template v-else-if="hasError">
<gl-icon class="gl-mr-auto" name="warning-solid" />
<span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
</template>
<template v-else>
<a :href="status.detailsPath" class="gl-mr-auto">
<ci-icon :status="status" :size="18" />
</a>
<span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.pipelineInfo">
<template #id="{ content }">
<gl-link
:href="status.detailsPath"
class="pipeline-id gl-font-weight-normal pipeline-number"
target="_blank"
data-testid="pipeline-id"
>
{{ content }}{{ pipelineId }}</gl-link
>
</template>
<template #status>{{ status.text }}</template>
<template #commit>
<gl-link
:href="pipeline.commitPath"
class="commit-sha gl-font-weight-normal"
target="_blank"
data-testid="pipeline-commit"
>
{{ pipeline.shortSha }}
</gl-link>
</template>
</gl-sprintf>
</span>
</template>
</div>
</template>
query getPipeline($fullPath: ID!, $sha: String!) {
project(fullPath: $fullPath) @client {
pipeline(sha: $sha) {
commitPath
id
iid
shortSha
status
detailedStatus {
detailsPath
icon
group
text
}
}
}
}
......@@ -11,6 +11,29 @@ export const resolvers = {
}),
};
},
/* eslint-disable @gitlab/require-i18n-strings */
project() {
return {
__typename: 'Project',
pipeline: {
__typename: 'Pipeline',
commitPath: `/-/commit/aabbccdd`,
id: 'gid://gitlab/Ci::Pipeline/118',
iid: '28',
shortSha: 'aabbccdd',
status: 'SUCCESS',
detailedStatus: {
__typename: 'DetailedStatus',
detailsPath: '/root/sample-ci-project/-/pipelines/118"',
group: 'success',
icon: 'status_success',
text: 'passed',
},
},
};
},
/* eslint-enable @gitlab/require-i18n-strings */
},
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
......
......@@ -5,6 +5,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
......
---
name: pipeline_status_for_pipeline_editor
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53797
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321518
milestone: '13.10'
type: development
group: group::pipeline authoring
default_enabled: false
......@@ -21920,6 +21920,9 @@ msgstr ""
msgid "Pipeline|Canceled"
msgstr ""
msgid "Pipeline|Checking pipeline status"
msgstr ""
msgid "Pipeline|Checking pipeline status."
msgstr ""
......@@ -21971,6 +21974,9 @@ msgstr ""
msgid "Pipeline|Pipeline"
msgstr ""
msgid "Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}"
msgstr ""
msgid "Pipeline|Pipelines"
msgstr ""
......@@ -22028,6 +22034,9 @@ msgstr ""
msgid "Pipeline|Variables"
msgstr ""
msgid "Pipeline|We are currently unable to fetch pipeline data"
msgstr ""
msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue';
import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue';
import { mockLintResponse } from '../../mock_data';
describe('Pipeline editor header', () => {
let wrapper;
const mockProvide = {
glFeatures: {
pipelineStatusForPipelineEditor: true,
},
};
const createComponent = () => {
const createComponent = ({ provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorHeader, {
provide: {
...mockProvide,
...provide,
},
props: {
ciConfigData: mockLintResponse,
isCiConfigDataLoading: false,
......@@ -16,6 +26,7 @@ describe('Pipeline editor header', () => {
});
};
const findPipelineStatus = () => wrapper.findComponent(PipelineStatus);
const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
afterEach(() => {
......@@ -27,8 +38,27 @@ describe('Pipeline editor header', () => {
beforeEach(() => {
createComponent();
});
it('renders the pipeline status', () => {
expect(findPipelineStatus().exists()).toBe(true);
});
it('renders the validation segment', () => {
expect(findValidationSegment().exists()).toBe(true);
});
});
describe('with pipeline status feature flag off', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { pipelineStatusForPipelineEditor: false },
},
});
});
it('does not render the pipeline status', () => {
expect(findPipelineStatus().exists()).toBe(false);
});
});
});
import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
const mockProvide = {
projectFullPath: mockProjectFullPath,
};
describe('Pipeline Status', () => {
let wrapper;
let mockApollo;
let mockPipelineQuery;
const createComponent = ({ hasPipeline = true, isQueryLoading = false }) => {
const pipeline = hasPipeline
? { loading: isQueryLoading, ...mockProjectPipeline.pipeline }
: { loading: isQueryLoading };
wrapper = shallowMount(PipelineStatus, {
provide: mockProvide,
stubs: { GlLink, GlSprintf },
data: () => (hasPipeline ? { pipeline } : {}),
mocks: {
$apollo: {
queries: {
pipeline,
},
},
},
});
};
const createComponentWithApollo = () => {
const resolvers = {
Query: {
project: mockPipelineQuery,
},
};
mockApollo = createMockApollo([], resolvers);
wrapper = shallowMount(PipelineStatus, {
localVue,
apolloProvider: mockApollo,
provide: mockProvide,
stubs: { GlLink, GlSprintf },
data() {
return {
commitSha: mockCommitSha,
};
},
});
};
const findIcon = () => wrapper.findComponent(GlIcon);
const findCiIcon = () => wrapper.findComponent(CiIcon);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]');
const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
beforeEach(() => {
mockPipelineQuery = jest.fn();
});
afterEach(() => {
mockPipelineQuery.mockReset();
wrapper.destroy();
wrapper = null;
});
describe('while querying', () => {
it('renders loading icon', () => {
createComponent({ isQueryLoading: true, hasPipeline: false });
expect(findLoadingIcon().exists()).toBe(true);
expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading);
});
it('does not render loading icon if pipeline data is already set', () => {
createComponent({ isQueryLoading: true });
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('when querying data', () => {
describe('when data is set', () => {
beforeEach(async () => {
mockPipelineQuery.mockResolvedValue(mockProjectPipeline);
createComponentWithApollo();
await waitForPromises();
});
it('query is called with correct variables', async () => {
expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
expect(mockPipelineQuery).toHaveBeenCalledWith(
expect.anything(),
{
fullPath: mockProjectFullPath,
},
expect.anything(),
expect.anything(),
);
});
it('does not render error', () => {
expect(findIcon().exists()).toBe(false);
});
it('renders pipeline data', () => {
const { id } = mockProjectPipeline.pipeline;
expect(findCiIcon().exists()).toBe(true);
expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`);
expect(findPipelineCommit().text()).toBe(mockCommitSha);
});
});
describe('when data cannot be fetched', () => {
beforeEach(async () => {
mockPipelineQuery.mockRejectedValue(new Error());
createComponentWithApollo();
await waitForPromises();
});
it('renders error', () => {
expect(findIcon().attributes('name')).toBe('warning-solid');
expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError);
});
it('does not render pipeline data', () => {
expect(findCiIcon().exists()).toBe(false);
expect(findPipelineId().exists()).toBe(false);
expect(findPipelineCommit().exists()).toBe(false);
});
});
});
});
......@@ -46,6 +46,24 @@ describe('~/pipeline_editor/graphql/resolvers', () => {
await expect(result.rawData).resolves.toBe(mockCiYml);
});
});
describe('pipeline', () => {
it('resolves pipeline data with type names', async () => {
const result = await resolvers.Query.project(null);
// eslint-disable-next-line no-underscore-dangle
expect(result.__typename).toBe('Project');
});
it('resolves pipeline data with necessary data', async () => {
const result = await resolvers.Query.project(null);
const pipelineKeys = Object.keys(result.pipeline);
const statusKeys = Object.keys(result.pipeline.detailedStatus);
expect(pipelineKeys).toContain('id', 'commitPath', 'detailedStatus', 'shortSha');
expect(statusKeys).toContain('detailsPath', 'text');
});
});
});
describe('Mutation', () => {
......
......@@ -138,6 +138,22 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
};
};
export const mockProjectPipeline = {
pipeline: {
commitPath: '/-/commit/aabbccdd',
id: 'gid://gitlab/Ci::Pipeline/118',
iid: '28',
shortSha: mockCommitSha,
status: 'SUCCESS',
detailedStatus: {
detailsPath: '/root/sample-ci-project/-/pipelines/118"',
group: 'success',
icon: 'status_success',
text: 'passed',
},
},
};
export const mockLintResponse = {
valid: true,
mergedYaml: mockCiYml,
......
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