Commit a920b02c authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch 'fc-graphql-pipeline-header' into 'master'

Move pipeline header to GraphQL

See merge request gitlab-org/gitlab!39494
parents b4991678 f12c8b24
/* 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 */ /* Interaction handles */
export const IS_HIGHLIGHTED = 'dag-highlighted'; export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link'; export const LINK_SELECTOR = 'dag-link';
......
...@@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql'; ...@@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql';
import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue'; import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue'; import DagAnnotations from './dag_annotations.vue';
import { import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
DEFAULT,
PARSE_FAILURE,
LOAD_FAILURE,
UNSUPPORTED_DATA,
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
} from './constants';
import { parseData } from './parsing_utils'; import { parseData } from './parsing_utils';
import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default { export default {
// eslint-disable-next-line @gitlab/require-i18n-strings // eslint-disable-next-line @gitlab/require-i18n-strings
......
<script> <script>
import * as d3 from 'd3'; import * as d3 from 'd3';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
LINK_SELECTOR,
NODE_SELECTOR,
PARSE_FAILURE,
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
} from './constants';
import { import {
currentIsLive, currentIsLive,
getLiveLinksAsDict, getLiveLinksAsDict,
...@@ -19,6 +12,7 @@ import { ...@@ -19,6 +12,7 @@ import {
} from './interactions'; } from './interactions';
import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
import { PARSE_FAILURE } from '../../constants';
export default { export default {
viewOptions: { viewOptions: {
......
<script> <script>
import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import { __ } from '~/locale'; 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'; const DELETE_MODAL_ID = 'pipeline-delete-modal';
...@@ -10,57 +13,143 @@ export default { ...@@ -10,57 +13,143 @@ export default {
name: 'PipelineHeaderSection', name: 'PipelineHeaderSection',
components: { components: {
ciHeader, ciHeader,
GlAlert,
GlButton,
GlLoadingIcon, GlLoadingIcon,
GlModal, GlModal,
GlButton,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
props: { errorTexts: {
pipeline: { [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
type: Object, [POST_FAILURE]: __('An error occurred while making the request.'),
required: true, [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: { pipelineIid: {
type: Boolean, default: '',
required: true, },
},
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() { data() {
return { return {
pipeline: null,
failureType: null,
isCanceling: false, isCanceling: false,
isRetrying: false, isRetrying: false,
isDeleting: false, isDeleting: false,
}; };
}, },
computed: { computed: {
status() {
return this.pipeline.details && this.pipeline.details.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
deleteModalConfirmationText() { deleteModalConfirmationText() {
return __( 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.', '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: { 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; this.isCanceling = true;
eventHub.$emit('headerPostAction', this.pipeline.cancel_path); this.postAction(this.paths.cancel);
}, },
retryPipeline() { async retryPipeline() {
this.isRetrying = true; this.isRetrying = true;
eventHub.$emit('headerPostAction', this.pipeline.retry_path); this.postAction(this.paths.retry);
}, },
deletePipeline() { async deletePipeline() {
this.isDeleting = true; 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, DELETE_MODAL_ID,
...@@ -68,54 +157,53 @@ export default { ...@@ -68,54 +157,53 @@ export default {
</script> </script>
<template> <template>
<div class="pipeline-header-container"> <div class="pipeline-header-container">
<gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert>
<ci-header <ci-header
v-if="shouldRenderContent" v-if="shouldRenderContent"
:status="status" :status="pipeline.detailedStatus"
:item-id="pipeline.id" :time="pipeline.createdAt"
:time="pipeline.created_at"
:user="pipeline.user" :user="pipeline.user"
:item-id="Number(pipelineId)"
item-name="Pipeline" item-name="Pipeline"
> >
<gl-button <gl-button
v-if="pipeline.retry_path" v-if="pipeline.retryable"
:loading="isRetrying" :loading="isRetrying"
:disabled="isRetrying" :disabled="isRetrying"
data-testid="retryButton"
category="secondary" category="secondary"
variant="info" variant="info"
data-testid="retryPipeline"
class="js-retry-button"
@click="retryPipeline()" @click="retryPipeline()"
> >
{{ __('Retry') }} {{ __('Retry') }}
</gl-button> </gl-button>
<gl-button <gl-button
v-if="pipeline.cancel_path" v-if="pipeline.cancelable"
:loading="isCanceling" :loading="isCanceling"
:disabled="isCanceling" :disabled="isCanceling"
data-testid="cancelPipeline"
class="gl-ml-3"
category="primary"
variant="danger" variant="danger"
data-testid="cancelPipeline"
@click="cancelPipeline()" @click="cancelPipeline()"
> >
{{ __('Cancel running') }} {{ __('Cancel running') }}
</gl-button> </gl-button>
<gl-button <gl-button
v-if="pipeline.delete_path" v-if="pipeline.userPermissions.destroyPipeline"
v-gl-modal="$options.DELETE_MODAL_ID" v-gl-modal="$options.DELETE_MODAL_ID"
:loading="isDeleting" :loading="isDeleting"
:disabled="isDeleting" :disabled="isDeleting"
data-testid="deletePipeline"
class="gl-ml-3" class="gl-ml-3"
category="secondary"
variant="danger" variant="danger"
category="secondary"
data-testid="deletePipeline"
> >
{{ __('Delete') }} {{ __('Delete') }}
</gl-button> </gl-button>
</ci-header> </ci-header>
<gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal <gl-modal
:modal-id="$options.DELETE_MODAL_ID" :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 ...@@ -21,3 +21,11 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project
export const RAW_TEXT_WARNING = s__( export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', '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'; ...@@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue';
import createDagApp from './pipeline_details_dag'; import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator'; 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 eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue'; import TestReports from './components/test_reports/test_reports.vue';
import createTestReportsStore from './stores/test_reports'; import createTestReportsStore from './stores/test_reports';
import { createPipelineHeaderApp } from './pipeline_details_header';
Vue.use(Translate); Vue.use(Translate);
...@@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => { ...@@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => {
}); });
}; };
const createPipelineHeaderApp = mediator => { const createLegacyPipelineHeaderApp = mediator => {
if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) { if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) {
return; return;
} }
...@@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => { ...@@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => {
new Vue({ new Vue({
el: SELECTORS.PIPELINE_HEADER, el: SELECTORS.PIPELINE_HEADER,
components: { components: {
pipelineHeader, legacyPipelineHeader,
}, },
data() { data() {
return { return {
...@@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => { ...@@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => {
}, },
}, },
render(createElement) { render(createElement) {
return createElement('pipeline-header', { return createElement('legacy-pipeline-header', {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline, pipeline: this.mediator.store.state.pipeline,
...@@ -132,7 +133,12 @@ export default () => { ...@@ -132,7 +133,12 @@ export default () => {
mediator.fetchPipeline(); mediator.fetchPipeline();
createPipelinesDetailApp(mediator); createPipelinesDetailApp(mediator);
createPipelineHeaderApp(mediator);
if (gon.features.graphqlPipelineHeader) {
createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
} else {
createLegacyPipelineHeaderApp(mediator);
}
createTestDetails(); createTestDetails();
createDagApp(); 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'; ...@@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue';
* *
* Receives status object containing: * Receives status object containing:
* status: { * 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 * group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon * icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip * label:"running" // used for potential tooltip
...@@ -46,6 +46,13 @@ export default { ...@@ -46,6 +46,13 @@ export default {
}, },
}, },
computed: { 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() { cssClass() {
const className = this.status.group; const className = this.status.group;
return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge'; return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
...@@ -54,12 +61,7 @@ export default { ...@@ -54,12 +61,7 @@ export default {
}; };
</script> </script>
<template> <template>
<a <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
v-gl-tooltip
:href="status.details_path"
:class="cssClass"
:title="!showText ? status.text : ''"
>
<ci-icon :status="status" :css-classes="iconClasses" /> <ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText"> <template v-if="showText">
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui'; import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import CiIconBadge from './ci_badge_link.vue'; import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue'; import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.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 * Renders header component for job and pipeline page based on UI mockups
...@@ -20,10 +21,12 @@ export default { ...@@ -20,10 +21,12 @@ export default {
UserAvatarImage, UserAvatarImage,
GlLink, GlLink,
GlDeprecatedButton, GlDeprecatedButton,
GlTooltip,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
EMOJI_REF: 'EMOJI_REF',
props: { props: {
status: { status: {
type: Object, type: Object,
...@@ -62,6 +65,27 @@ export default { ...@@ -62,6 +65,27 @@ export default {
userAvatarAltText() { userAvatarAltText() {
return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); 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: { methods: {
...@@ -73,7 +97,7 @@ export default { ...@@ -73,7 +97,7 @@ export default {
</script> </script>
<template> <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"> <section class="header-main-content">
<ci-icon-badge :status="status" /> <ci-icon-badge :status="status" />
...@@ -89,12 +113,12 @@ export default { ...@@ -89,12 +113,12 @@ export default {
<template v-if="user"> <template v-if="user">
<gl-link <gl-link
v-gl-tooltip v-gl-tooltip
:href="user.path" :href="userPath"
:title="user.email" :title="user.email"
class="js-user-link commit-committer-link" class="js-user-link commit-committer-link"
> >
<user-avatar-image <user-avatar-image
:img-src="user.avatar_url" :img-src="avatarUrl"
:img-alt="userAvatarAltText" :img-alt="userAvatarAltText"
:tooltip-text="user.name" :tooltip-text="user.name"
:img-size="24" :img-size="24"
...@@ -102,7 +126,15 @@ export default { ...@@ -102,7 +126,15 @@ export default {
{{ user.name }} {{ user.name }}
</gl-link> </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> </template>
</section> </section>
......
...@@ -16,6 +16,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -16,6 +16,7 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true) push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true)
push_frontend_feature_flag(:pipelines_security_report_summary, project) push_frontend_feature_flag(:pipelines_security_report_summary, project)
push_frontend_feature_flag(:new_pipeline_form) push_frontend_feature_flag(:new_pipeline_form)
push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false)
end end
before_action :ensure_pipeline, only: [:show] before_action :ensure_pipeline, only: [:show]
......
...@@ -4,8 +4,7 @@ ...@@ -4,8 +4,7 @@
- pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present? - pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present?
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } .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? - if @pipeline.commit.present?
= render "projects/pipelines/info", commit: @pipeline.commit = 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
...@@ -3004,6 +3004,9 @@ msgstr "" ...@@ -3004,6 +3004,9 @@ msgstr ""
msgid "An unknown error occurred while loading this graph." msgid "An unknown error occurred while loading this graph."
msgstr "" msgstr ""
msgid "An unknown error occurred."
msgstr ""
msgid "Analytics" msgid "Analytics"
msgstr "" msgstr ""
...@@ -28472,6 +28475,9 @@ msgstr "" ...@@ -28472,6 +28475,9 @@ msgstr ""
msgid "Warning: Displaying this diagram might cause performance issues on this page." msgid "Warning: Displaying this diagram might cause performance issues on this page."
msgstr "" 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." msgid "We are currently unable to fetch data for this graph."
msgstr "" msgstr ""
......
...@@ -140,6 +140,7 @@ RSpec.describe 'Commits' do ...@@ -140,6 +140,7 @@ RSpec.describe 'Commits' do
context 'when accessing internal project with disallowed access', :js do context 'when accessing internal project with disallowed access', :js do
before do before do
stub_feature_flags(graphql_pipeline_header: false)
project.update( project.update(
visibility_level: Gitlab::VisibilityLevel::INTERNAL, visibility_level: Gitlab::VisibilityLevel::INTERNAL,
public_builds: false) public_builds: false)
......
...@@ -172,10 +172,17 @@ RSpec.describe 'Pipeline', :js do ...@@ -172,10 +172,17 @@ RSpec.describe 'Pipeline', :js do
end end
end end
it_behaves_like 'showing user status' do describe 'pipelines details view' do
let(:user_with_status) { pipeline.user } 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 end
describe 'pipeline graph' do describe 'pipeline graph' do
...@@ -400,7 +407,7 @@ RSpec.describe 'Pipeline', :js do ...@@ -400,7 +407,7 @@ RSpec.describe 'Pipeline', :js do
context 'when retrying' do context 'when retrying' do
before do before do
find('[data-testid="retryButton"]').click find('[data-testid="retryPipeline"]').click
end end
it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do
...@@ -902,7 +909,7 @@ RSpec.describe 'Pipeline', :js do ...@@ -902,7 +909,7 @@ RSpec.describe 'Pipeline', :js do
context 'when retrying' do context 'when retrying' do
before do before do
find('[data-testid="retryButton"]').click find('[data-testid="retryPipeline"]').click
end end
it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do 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'; ...@@ -4,13 +4,8 @@ import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
import { import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants';
ADD_NOTE, import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants';
REMOVE_NOTE,
REPLACE_NOTES,
PARSE_FAILURE,
UNSUPPORTED_DATA,
} from '~/pipelines/components/dag//constants';
import { import {
mockParsedGraphQLNodes, mockParsedGraphQLNodes,
tooSmallGraph, tooSmallGraph,
......
import { shallowMount } from '@vue/test-utils'; 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 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', () => { describe('Pipeline details header', () => {
let wrapper; let wrapper;
let glModalDirective; let glModalDirective;
let mockAxios;
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
const findDeleteModal = () => wrapper.find(GlModal); const findDeleteModal = () => wrapper.find(GlModal);
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
const defaultProps = { const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]');
pipeline: { const findDeleteButton = () => wrapper.find('[data-testid="deletePipeline"]');
details: { const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
status: {
group: 'failed', const defaultProvideOptions = {
icon: 'status_failed', pipelineId: 14,
label: 'failed', pipelineIid: 1,
text: 'failed', paths: {
details_path: 'path', retry: '/retry',
}, cancel: '/cancel',
}, delete: '/delete',
id: 123, fullProject: '/namespace/my-project',
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 = {}) => { const createComponent = (pipelineMock = mockRunningPipelineHeader, { isLoading } = false) => {
glModalDirective = jest.fn(); glModalDirective = jest.fn();
wrapper = shallowMount(HeaderComponent, { const $apollo = {
propsData: { queries: {
...props, pipeline: {
loading: isLoading,
stopPolling: jest.fn(),
startPolling: jest.fn(),
},
},
};
return shallowMount(HeaderComponent, {
data() {
return {
pipeline: pipelineMock,
};
},
provide: {
...defaultProvideOptions,
}, },
directives: { directives: {
glModal: { glModal: {
bind(el, { value }) { bind(_, { value }) {
glModalDirective(value); glModalDirective(value);
}, },
}, },
}, },
mocks: { $apollo },
}); });
}; };
beforeEach(() => { beforeEach(() => {
jest.spyOn(eventHub, '$emit'); mockAxios = new MockAdapter(axios);
mockAxios.onGet('*').replyOnce(200);
createComponent(defaultProps);
}); });
afterEach(() => { afterEach(() => {
eventHub.$off();
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
mockAxios.restore();
}); });
it('should render provided pipeline info', () => { describe('initial loading', () => {
expect(wrapper.find(CiHeader).props()).toMatchObject({ beforeEach(() => {
status: defaultProps.pipeline.details.status, wrapper = createComponent(null, { isLoading: true });
itemId: defaultProps.pipeline.id,
time: defaultProps.pipeline.created_at,
user: defaultProps.pipeline.user,
}); });
});
describe('action buttons', () => { it('shows a loading state while graphQL is fetching initial data', () => {
it('should not trigger eventHub when nothing happens', () => { expect(findLoadingIcon().exists()).toBe(true);
expect(eventHub.$emit).not.toHaveBeenCalled();
}); });
});
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', () => { describe('actions', () => {
wrapper.find('[data-testid="retryButton"]').vm.$emit('click'); 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', () => { await wrapper.vm.$nextTick();
wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel'); expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry);
});
}); });
it('does not show delete modal', () => { describe('Cancel action', () => {
expect(findDeleteModal()).not.toBeVisible(); 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', () => { describe('Delete action', () => {
it('displays delete modal', () => { 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(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
expect(glModalDirective).toHaveBeenCalledWith(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'); 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 = { export const pipelineWithStages = {
id: 20333396, id: 20333396,
user: { user: {
...@@ -320,6 +324,80 @@ export const pipelineWithStages = { ...@@ -320,6 +324,80 @@ export const pipelineWithStages = {
triggered: [], 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 = { export const stageReply = {
name: 'deploy', name: 'deploy',
title: 'deploy: running', 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