Commit f12c8b24 authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Sarah Groff Hennigh-Palermo

Implement GraphQL for pipeline header

Add GraphQL and Apollo in the pipeline
header instead of REST services.
parent 9e72553a
/* Error constants */
export const PARSE_FAILURE = 'parse_failure';
export const LOAD_FAILURE = 'load_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const DEFAULT = 'default';
/* Interaction handles */
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
......
......@@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql';
import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
import {
DEFAULT,
PARSE_FAILURE,
LOAD_FAILURE,
UNSUPPORTED_DATA,
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
} from './constants';
import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import { parseData } from './parsing_utils';
import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
......
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
import {
LINK_SELECTOR,
NODE_SELECTOR,
PARSE_FAILURE,
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
} from './constants';
import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import {
currentIsLive,
getLiveLinksAsDict,
......@@ -19,6 +12,7 @@ import {
} from './interactions';
import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
import { PARSE_FAILURE } from '../../constants';
export default {
viewOptions: {
......
<script>
import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
......@@ -10,57 +13,143 @@ export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
GlAlert,
GlButton,
GlLoadingIcon,
GlModal,
GlButton,
},
directives: {
GlModal: GlModalDirective,
},
props: {
pipeline: {
type: Object,
required: true,
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
[POST_FAILURE]: __('An error occurred while making the request.'),
[DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
[DEFAULT]: __('An unknown error occurred.'),
},
inject: {
// Receive `cancel`, `delete`, `fullProject` and `retry`
paths: {
default: {},
},
pipelineId: {
default: '',
},
isLoading: {
type: Boolean,
required: true,
pipelineIid: {
default: '',
},
},
apollo: {
pipeline: {
query: getPipelineQuery,
variables() {
return {
fullPath: this.paths.fullProject,
iid: this.pipelineIid,
};
},
update: data => data.project.pipeline,
error() {
this.reportFailure(LOAD_FAILURE);
},
pollInterval: 10000,
watchLoading(isLoading) {
if (!isLoading) {
// To ensure apollo has updated the cache,
// we only remove the loading state in sync with GraphQL
this.isCanceling = false;
this.isRetrying = false;
}
},
},
},
data() {
return {
pipeline: null,
failureType: null,
isCanceling: false,
isRetrying: false,
isDeleting: false,
};
},
computed: {
status() {
return this.pipeline.details && this.pipeline.details.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
deleteModalConfirmationText() {
return __(
'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
);
},
hasError() {
return this.failureType;
},
hasPipelineData() {
return Boolean(this.pipeline);
},
isLoadingInitialQuery() {
return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
},
status() {
return this.pipeline?.status;
},
shouldRenderContent() {
return !this.isLoadingInitialQuery && this.hasPipelineData;
},
failure() {
switch (this.failureType) {
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
variant: 'danger',
};
case POST_FAILURE:
return {
text: this.$options.errorTexts[POST_FAILURE],
variant: 'danger',
};
case DELETE_FAILURE:
return {
text: this.$options.errorTexts[DELETE_FAILURE],
variant: 'danger',
};
default:
return {
text: this.$options.errorTexts[DEFAULT],
variant: 'danger',
};
}
},
},
methods: {
cancelPipeline() {
reportFailure(errorType) {
this.failureType = errorType;
},
async postAction(path) {
try {
await axios.post(path);
this.$apollo.queries.pipeline.refetch();
} catch {
this.reportFailure(POST_FAILURE);
}
},
async cancelPipeline() {
this.isCanceling = true;
eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
this.postAction(this.paths.cancel);
},
retryPipeline() {
async retryPipeline() {
this.isRetrying = true;
eventHub.$emit('headerPostAction', this.pipeline.retry_path);
this.postAction(this.paths.retry);
},
deletePipeline() {
async deletePipeline() {
this.isDeleting = true;
eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
this.$apollo.queries.pipeline.stopPolling();
try {
const { request } = await axios.delete(this.paths.delete);
redirectTo(setUrlFragment(request.responseURL, 'delete_success'));
} catch {
this.$apollo.queries.pipeline.startPolling();
this.reportFailure(DELETE_FAILURE);
this.isDeleting = false;
}
},
},
DELETE_MODAL_ID,
......@@ -68,54 +157,53 @@ export default {
</script>
<template>
<div class="pipeline-header-container">
<gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert>
<ci-header
v-if="shouldRenderContent"
:status="status"
:item-id="pipeline.id"
:time="pipeline.created_at"
:status="pipeline.detailedStatus"
:time="pipeline.createdAt"
:user="pipeline.user"
:item-id="Number(pipelineId)"
item-name="Pipeline"
>
<gl-button
v-if="pipeline.retry_path"
v-if="pipeline.retryable"
:loading="isRetrying"
:disabled="isRetrying"
data-testid="retryButton"
category="secondary"
variant="info"
data-testid="retryPipeline"
class="js-retry-button"
@click="retryPipeline()"
>
{{ __('Retry') }}
</gl-button>
<gl-button
v-if="pipeline.cancel_path"
v-if="pipeline.cancelable"
:loading="isCanceling"
:disabled="isCanceling"
data-testid="cancelPipeline"
class="gl-ml-3"
category="primary"
variant="danger"
data-testid="cancelPipeline"
@click="cancelPipeline()"
>
{{ __('Cancel running') }}
</gl-button>
<gl-button
v-if="pipeline.delete_path"
v-if="pipeline.userPermissions.destroyPipeline"
v-gl-modal="$options.DELETE_MODAL_ID"
:loading="isDeleting"
:disabled="isDeleting"
data-testid="deletePipeline"
class="gl-ml-3"
category="secondary"
variant="danger"
category="secondary"
data-testid="deletePipeline"
>
{{ __('Delete') }}
</gl-button>
</ci-header>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
......
<script>
import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import { __ } from '~/locale';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
GlLoadingIcon,
GlModal,
GlButton,
},
directives: {
GlModal: GlModalDirective,
},
props: {
pipeline: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
data() {
return {
isCanceling: false,
isRetrying: false,
isDeleting: false,
};
},
computed: {
status() {
return this.pipeline.details && this.pipeline.details.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
deleteModalConfirmationText() {
return __(
'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
);
},
},
methods: {
cancelPipeline() {
this.isCanceling = true;
eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
},
retryPipeline() {
this.isRetrying = true;
eventHub.$emit('headerPostAction', this.pipeline.retry_path);
},
deletePipeline() {
this.isDeleting = true;
eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
},
},
DELETE_MODAL_ID,
};
</script>
<template>
<div class="pipeline-header-container">
<ci-header
v-if="shouldRenderContent"
:status="status"
:item-id="pipeline.id"
:time="pipeline.created_at"
:user="pipeline.user"
item-name="Pipeline"
>
<gl-button
v-if="pipeline.retry_path"
:loading="isRetrying"
:disabled="isRetrying"
data-testid="retryButton"
category="secondary"
variant="info"
@click="retryPipeline()"
>
{{ __('Retry') }}
</gl-button>
<gl-button
v-if="pipeline.cancel_path"
:loading="isCanceling"
:disabled="isCanceling"
data-testid="cancelPipeline"
class="gl-ml-3"
category="primary"
variant="danger"
@click="cancelPipeline()"
>
{{ __('Cancel running') }}
</gl-button>
<gl-button
v-if="pipeline.delete_path"
v-gl-modal="$options.DELETE_MODAL_ID"
:loading="isDeleting"
:disabled="isDeleting"
data-testid="deletePipeline"
class="gl-ml-3"
category="secondary"
variant="danger"
>
{{ __('Delete') }}
</gl-button>
</ci-header>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
:title="__('Delete pipeline')"
:ok-title="__('Delete pipeline')"
ok-variant="danger"
@ok="deletePipeline()"
>
<p>
{{ deleteModalConfirmationText }}
</p>
</gl-modal>
</div>
</template>
......@@ -21,3 +21,11 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
/* Error constants shared across graphs */
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
pipeline(iid: $iid) {
id
status
retryable
cancelable
userPermissions {
destroyPipeline
}
detailedStatus {
detailsPath
icon
group
text
}
createdAt
user {
name
webPath
email
avatarUrl
status {
message
emoji
}
}
}
}
}
......@@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue';
import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
import legacyPipelineHeader from './components/legacy_header_component.vue';
import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
import createTestReportsStore from './stores/test_reports';
import { createPipelineHeaderApp } from './pipeline_details_header';
Vue.use(Translate);
......@@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => {
});
};
const createPipelineHeaderApp = mediator => {
const createLegacyPipelineHeaderApp = mediator => {
if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) {
return;
}
......@@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => {
new Vue({
el: SELECTORS.PIPELINE_HEADER,
components: {
pipelineHeader,
legacyPipelineHeader,
},
data() {
return {
......@@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => {
},
},
render(createElement) {
return createElement('pipeline-header', {
return createElement('legacy-pipeline-header', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
......@@ -132,7 +133,12 @@ export default () => {
mediator.fetchPipeline();
createPipelinesDetailApp(mediator);
createPipelineHeaderApp(mediator);
if (gon.features.graphqlPipelineHeader) {
createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
} else {
createLegacyPipelineHeaderApp(mediator);
}
createTestDetails();
createDagApp();
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import pipelineHeader from './components/header_component.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export const createPipelineHeaderApp = elSelector => {
const el = document.querySelector(elSelector);
if (!el) {
return;
}
const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
pipelineHeader,
},
apolloProvider,
provide: {
paths: {
cancel: cancelPath,
delete: deletePath,
fullProject: fullPath,
retry: retryPath,
},
pipelineId,
pipelineIid,
},
render(createElement) {
return createElement('pipeline-header', {});
},
});
};
......@@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue';
*
* Receives status object containing:
* status: {
* details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
* details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
* group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip
......@@ -46,6 +46,13 @@ export default {
},
},
computed: {
title() {
return !this.showText ? this.status?.text : '';
},
detailsPath() {
// For now, this can either come from graphQL with camelCase or REST API in snake_case
return this.status.detailsPath || this.status.details_path;
},
cssClass() {
const className = this.status.group;
return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
......@@ -54,12 +61,7 @@ export default {
};
</script>
<template>
<a
v-gl-tooltip
:href="status.details_path"
:class="cssClass"
:title="!showText ? status.text : ''"
>
<a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
......
<script>
/* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlTooltip } from '@gitlab/ui';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '../../locale';
/**
* Renders header component for job and pipeline page based on UI mockups
......@@ -20,10 +21,12 @@ export default {
UserAvatarImage,
GlLink,
GlDeprecatedButton,
GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
EMOJI_REF: 'EMOJI_REF',
props: {
status: {
type: Object,
......@@ -62,6 +65,27 @@ export default {
userAvatarAltText() {
return sprintf(__(`%{username}'s avatar`), { username: this.user.name });
},
userPath() {
// GraphQL returns `webPath` and Rest `path`
return this.user?.webPath || this.user?.path;
},
avatarUrl() {
// GraphQL returns `avatarUrl` and Rest `avatar_url`
return this.user?.avatarUrl || this.user?.avatar_url;
},
statusTooltipHTML() {
// Rest `status_tooltip_html` which is a ready to work
// html for the emoji and the status text inside a tooltip.
// GraphQL returns `status.emoji` and `status.message` which
// needs to be combined to make the html we want.
const { emoji } = this.user?.status || {};
const emojiHtml = emoji ? glEmojiTag(emoji) : '';
return emojiHtml || this.user?.status_tooltip_html;
},
message() {
return this.user?.status?.message;
},
},
methods: {
......@@ -73,7 +97,7 @@ export default {
</script>
<template>
<header class="page-content-header ci-header-container">
<header class="page-content-header ci-header-container" data-testid="pipeline-header-content">
<section class="header-main-content">
<ci-icon-badge :status="status" />
......@@ -89,12 +113,12 @@ export default {
<template v-if="user">
<gl-link
v-gl-tooltip
:href="user.path"
:href="userPath"
:title="user.email"
class="js-user-link commit-committer-link"
>
<user-avatar-image
:img-src="user.avatar_url"
:img-src="avatarUrl"
:img-alt="userAvatarAltText"
:tooltip-text="user.name"
:img-size="24"
......@@ -102,7 +126,15 @@ export default {
{{ user.name }}
</gl-link>
<span v-if="user.status_tooltip_html" v-html="user.status_tooltip_html"></span>
<gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
{{ message }}
</gl-tooltip>
<span
v-if="statusTooltipHTML"
:ref="$options.EMOJI_REF"
:data-testid="message"
v-html="statusTooltipHTML"
></span>
</template>
</section>
......
......@@ -16,6 +16,7 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true)
push_frontend_feature_flag(:pipelines_security_report_summary, project)
push_frontend_feature_flag(:new_pipeline_form)
push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false)
end
before_action :ensure_pipeline, only: [:show]
......
......@@ -4,8 +4,7 @@
- pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present?
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
#js-pipeline-header-vue.pipeline-header-container
#js-pipeline-header-vue.pipeline-header-container{ data: {full_path: @project.full_path, retry_path: retry_project_pipeline_path(@pipeline.project, @pipeline), cancel_path: cancel_project_pipeline_path(@pipeline.project, @pipeline), delete_path: project_pipeline_path(@pipeline.project, @pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id} }
- if @pipeline.commit.present?
= render "projects/pipelines/info", commit: @pipeline.commit
......
---
name: graphql_pipeline_header
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39494
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254235
group: group::pipeline authoring
type: development
default_enabled: false
......@@ -3003,6 +3003,9 @@ msgstr ""
msgid "An unknown error occurred while loading this graph."
msgstr ""
msgid "An unknown error occurred."
msgstr ""
msgid "Analytics"
msgstr ""
......@@ -28468,6 +28471,9 @@ msgstr ""
msgid "Warning: Displaying this diagram might cause performance issues on this page."
msgstr ""
msgid "We are currently unable to fetch data for the pipeline header."
msgstr ""
msgid "We are currently unable to fetch data for this graph."
msgstr ""
......
......@@ -140,6 +140,7 @@ RSpec.describe 'Commits' do
context 'when accessing internal project with disallowed access', :js do
before do
stub_feature_flags(graphql_pipeline_header: false)
project.update(
visibility_level: Gitlab::VisibilityLevel::INTERNAL,
public_builds: false)
......
......@@ -172,10 +172,17 @@ RSpec.describe 'Pipeline', :js do
end
end
it_behaves_like 'showing user status' do
let(:user_with_status) { pipeline.user }
describe 'pipelines details view' do
let!(:status) { create(:user_status, user: pipeline.user, emoji: 'smirk', message: 'Authoring this object') }
subject { visit project_pipeline_path(project, pipeline) }
it 'pipeline header shows the user status and emoji' do
visit project_pipeline_path(project, pipeline)
within '[data-testid="pipeline-header-content"]' do
expect(page).to have_selector("[data-testid='#{status.message}']")
expect(page).to have_selector("[data-name='#{status.emoji}']")
end
end
end
describe 'pipeline graph' do
......@@ -400,7 +407,7 @@ RSpec.describe 'Pipeline', :js do
context 'when retrying' do
before do
find('[data-testid="retryButton"]').click
find('[data-testid="retryPipeline"]').click
end
it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do
......@@ -902,7 +909,7 @@ RSpec.describe 'Pipeline', :js do
context 'when retrying' do
before do
find('[data-testid="retryButton"]').click
find('[data-testid="retryPipeline"]').click
end
it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do
......
......@@ -4,13 +4,8 @@ import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
import {
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
PARSE_FAILURE,
UNSUPPORTED_DATA,
} from '~/pipelines/components/dag//constants';
import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants';
import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants';
import {
mockParsedGraphQLNodes,
tooSmallGraph,
......
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import {
mockCancelledPipelineHeader,
mockFailedPipelineHeader,
mockRunningPipelineHeader,
mockSuccessfulPipelineHeader,
} from './mock_data';
import axios from '~/lib/utils/axios_utils';
import HeaderComponent from '~/pipelines/components/header_component.vue';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import eventHub from '~/pipelines/event_hub';
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
let mockAxios;
const findDeleteModal = () => wrapper.find(GlModal);
const defaultProps = {
pipeline: {
details: {
status: {
group: 'failed',
icon: 'status_failed',
label: 'failed',
text: 'failed',
details_path: 'path',
},
},
id: 123,
created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'retry',
cancel_path: 'cancel',
delete_path: 'delete',
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]');
const findDeleteButton = () => wrapper.find('[data-testid="deletePipeline"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const defaultProvideOptions = {
pipelineId: 14,
pipelineIid: 1,
paths: {
retry: '/retry',
cancel: '/cancel',
delete: '/delete',
fullProject: '/namespace/my-project',
},
isLoading: false,
};
const createComponent = (props = {}) => {
const createComponent = (pipelineMock = mockRunningPipelineHeader, { isLoading } = false) => {
glModalDirective = jest.fn();
wrapper = shallowMount(HeaderComponent, {
propsData: {
...props,
const $apollo = {
queries: {
pipeline: {
loading: isLoading,
stopPolling: jest.fn(),
startPolling: jest.fn(),
},
},
};
return shallowMount(HeaderComponent, {
data() {
return {
pipeline: pipelineMock,
};
},
provide: {
...defaultProvideOptions,
},
directives: {
glModal: {
bind(el, { value }) {
bind(_, { value }) {
glModalDirective(value);
},
},
},
mocks: { $apollo },
});
};
beforeEach(() => {
jest.spyOn(eventHub, '$emit');
createComponent(defaultProps);
mockAxios = new MockAdapter(axios);
mockAxios.onGet('*').replyOnce(200);
});
afterEach(() => {
eventHub.$off();
wrapper.destroy();
wrapper = null;
mockAxios.restore();
});
it('should render provided pipeline info', () => {
expect(wrapper.find(CiHeader).props()).toMatchObject({
status: defaultProps.pipeline.details.status,
itemId: defaultProps.pipeline.id,
time: defaultProps.pipeline.created_at,
user: defaultProps.pipeline.user,
describe('initial loading', () => {
beforeEach(() => {
wrapper = createComponent(null, { isLoading: true });
});
});
describe('action buttons', () => {
it('should not trigger eventHub when nothing happens', () => {
expect(eventHub.$emit).not.toHaveBeenCalled();
it('shows a loading state while graphQL is fetching initial data', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('visible state', () => {
it.each`
state | pipelineData | retryValue | cancelValue
${'cancelled'} | ${mockCancelledPipelineHeader} | ${true} | ${false}
${'failed'} | ${mockFailedPipelineHeader} | ${true} | ${false}
${'running'} | ${mockRunningPipelineHeader} | ${false} | ${true}
${'successful'} | ${mockSuccessfulPipelineHeader} | ${false} | ${false}
`(
'with a $state pipeline, it will show actions: retry $retryValue and cancel $cancelValue',
({ pipelineData, retryValue, cancelValue }) => {
wrapper = createComponent(pipelineData);
expect(findRetryButton().exists()).toBe(retryValue);
expect(findCancelButton().exists()).toBe(cancelValue);
},
);
});
it('should call postAction when retry button action is clicked', () => {
wrapper.find('[data-testid="retryButton"]').vm.$emit('click');
describe('actions', () => {
describe('Retry action', () => {
beforeEach(() => {
wrapper = createComponent(mockCancelledPipelineHeader);
});
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
});
it('should call axios with the right path when retry button is clicked', async () => {
jest.spyOn(axios, 'post');
findRetryButton().vm.$emit('click');
it('should call postAction when cancel button action is clicked', () => {
wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click');
await wrapper.vm.$nextTick();
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry);
});
});
it('does not show delete modal', () => {
expect(findDeleteModal()).not.toBeVisible();
describe('Cancel action', () => {
beforeEach(() => {
wrapper = createComponent(mockRunningPipelineHeader);
});
it('should call axios with the right path when cancel button is clicked', async () => {
jest.spyOn(axios, 'post');
findCancelButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.cancel);
});
});
describe('when delete button action is clicked', () => {
it('displays delete modal', () => {
describe('Delete action', () => {
beforeEach(() => {
wrapper = createComponent(mockFailedPipelineHeader);
});
it('displays delete modal when clicking on delete and does not call the delete action', async () => {
jest.spyOn(axios, 'delete');
findDeleteButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
expect(axios.delete).not.toHaveBeenCalled();
});
it('should call delete when modal is submitted', () => {
it('should call delete path when modal is submitted', async () => {
jest.spyOn(axios, 'delete');
findDeleteModal().vm.$emit('ok');
expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
await wrapper.vm.$nextTick();
expect(axios.delete).toHaveBeenCalledWith(defaultProvideOptions.paths.delete);
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import LegacyHeaderComponent from '~/pipelines/components/legacy_header_component.vue';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import eventHub from '~/pipelines/event_hub';
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
const findDeleteModal = () => wrapper.find(GlModal);
const defaultProps = {
pipeline: {
details: {
status: {
group: 'failed',
icon: 'status_failed',
label: 'failed',
text: 'failed',
details_path: 'path',
},
},
id: 123,
created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'retry',
cancel_path: 'cancel',
delete_path: 'delete',
},
isLoading: false,
};
const createComponent = (props = {}) => {
glModalDirective = jest.fn();
wrapper = shallowMount(LegacyHeaderComponent, {
propsData: {
...props,
},
directives: {
glModal: {
bind(el, { value }) {
glModalDirective(value);
},
},
},
});
};
beforeEach(() => {
jest.spyOn(eventHub, '$emit');
createComponent(defaultProps);
});
afterEach(() => {
eventHub.$off();
wrapper.destroy();
wrapper = null;
});
it('should render provided pipeline info', () => {
expect(wrapper.find(CiHeader).props()).toMatchObject({
status: defaultProps.pipeline.details.status,
itemId: defaultProps.pipeline.id,
time: defaultProps.pipeline.created_at,
user: defaultProps.pipeline.user,
});
});
describe('action buttons', () => {
it('should not trigger eventHub when nothing happens', () => {
expect(eventHub.$emit).not.toHaveBeenCalled();
});
it('should call postAction when retry button action is clicked', () => {
wrapper.find('[data-testid="retryButton"]').vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
});
it('should call postAction when cancel button action is clicked', () => {
wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
});
it('does not show delete modal', () => {
expect(findDeleteModal()).not.toBeVisible();
});
describe('when delete button action is clicked', () => {
it('displays delete modal', () => {
expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
});
it('should call delete when modal is submitted', () => {
findDeleteModal().vm.$emit('ok');
expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
});
});
});
});
const PIPELINE_RUNNING = 'RUNNING';
const PIPELINE_CANCELED = 'CANCELED';
const PIPELINE_FAILED = 'FAILED';
export const pipelineWithStages = {
id: 20333396,
user: {
......@@ -320,6 +324,80 @@ export const pipelineWithStages = {
triggered: [],
};
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
export const mockPipelineHeader = {
detailedStatus: {},
id: 123,
userPermissions: {
destroyPipeline: true,
},
createdAt: threeWeeksAgo.toISOString(),
user: {
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatarUrl: 'link',
},
};
export const mockFailedPipelineHeader = {
...mockPipelineHeader,
status: PIPELINE_FAILED,
retryable: true,
cancelable: false,
detailedStatus: {
group: 'failed',
icon: 'status_failed',
label: 'failed',
text: 'failed',
detailsPath: 'path',
},
};
export const mockRunningPipelineHeader = {
...mockPipelineHeader,
status: PIPELINE_RUNNING,
retryable: false,
cancelable: true,
detailedStatus: {
group: 'running',
icon: 'status_running',
label: 'running',
text: 'running',
detailsPath: 'path',
},
};
export const mockCancelledPipelineHeader = {
...mockPipelineHeader,
status: PIPELINE_CANCELED,
retryable: true,
cancelable: false,
detailedStatus: {
group: 'cancelled',
icon: 'status_cancelled',
label: 'cancelled',
text: 'cancelled',
detailsPath: 'path',
},
};
export const mockSuccessfulPipelineHeader = {
...mockPipelineHeader,
status: 'SUCCESS',
retryable: false,
cancelable: false,
detailedStatus: {
group: 'success',
icon: 'status_success',
label: 'success',
text: 'success',
detailsPath: 'path',
},
};
export const stageReply = {
name: 'deploy',
title: 'deploy: running',
......
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