Commit 3d8d36f5 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '326287-hover-tip' into 'master'

Add hover tip for show links toggle

See merge request gitlab-org/gitlab!60024
parents 1fd8f45d 186f2eee
......@@ -165,7 +165,7 @@ export default {
<div class="js-pipeline-graph">
<div
ref="mainPipelineContainer"
class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap"
class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap gl-border-t-solid gl-border-t-1 gl-border-gray-100"
:class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }"
>
<linked-graph-wrapper>
......
......@@ -5,6 +5,8 @@ import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
import { reportToSentry } from '../../utils';
import { listByLayers } from '../parsing_utils';
import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants';
......@@ -17,6 +19,9 @@ import {
unwrapPipelineData,
} from './utils';
const featureName = 'pipeline_needs_hover_tip';
const enumFeatureName = featureName.toUpperCase();
export default {
name: 'PipelineGraphWrapper',
components: {
......@@ -44,6 +49,7 @@ export default {
data() {
return {
alertType: null,
callouts: [],
currentViewType: STAGE_VIEW,
pipeline: null,
pipelineLayers: null,
......@@ -60,6 +66,18 @@ export default {
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
apollo: {
callouts: {
query: getUserCallouts,
update(data) {
return data?.currentUser?.callouts?.nodes.map((callout) => callout.featureName);
},
error(err) {
reportToSentry(
this.$options.name,
`type: callout_load_failure, info: ${serializeLoadErrors(err)}`,
);
},
},
pipeline: {
context() {
return getQueryHeaders(this.graphqlResourceEtag);
......@@ -142,6 +160,9 @@ export default {
/* This prevents reading view type off the localStorage value if it does not apply. */
return this.showGraphViewSelector ? this.currentViewType : STAGE_VIEW;
},
hoverTipPreviouslyDismissed() {
return this.callouts.includes(enumFeatureName);
},
showLoadingIcon() {
/*
Shows the icon only when the graph is empty, not when it is is
......@@ -171,6 +192,18 @@ export default {
return this.pipelineLayers;
},
handleTipDismissal() {
try {
this.$apollo.mutate({
mutation: DismissPipelineGraphCallout,
variables: {
featureName,
},
});
} catch (err) {
reportToSentry(this.$options.name, `type: callout_dismiss_failure, info: ${err}`);
}
},
hideAlert() {
this.showAlert = false;
this.alertType = null;
......@@ -211,6 +244,8 @@ export default {
v-if="showGraphViewSelector"
:type="graphViewType"
:show-links="showLinks"
:tip-previously-dismissed="hoverTipPreviouslyDismissed"
@dismissHoverTip="handleTipDismissal"
@updateViewType="updateViewType"
@updateShowLinksState="updateShowLinksState"
/>
......
<script>
import { GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui';
import { __ } from '~/locale';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
components: {
GlAlert,
GlLoadingIcon,
GlSegmentedControl,
GlToggle,
......@@ -15,6 +16,10 @@ export default {
type: Boolean,
required: true,
},
tipPreviouslyDismissed: {
type: Boolean,
required: true,
},
type: {
type: String,
required: true,
......@@ -22,15 +27,17 @@ export default {
},
data() {
return {
currentViewType: this.type,
showLinksActive: false,
hoverTipDismissed: false,
isToggleLoading: false,
isSwitcherLoading: false,
segmentSelectedType: this.type,
showLinksActive: false,
};
},
i18n: {
viewLabelText: __('Group jobs by'),
hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'),
linksLabelText: __('Show dependencies'),
viewLabelText: __('Group jobs by'),
},
views: {
[STAGE_VIEW]: {
......@@ -48,7 +55,15 @@ export default {
},
computed: {
showLinksToggle() {
return this.currentViewType === LAYER_VIEW;
return this.segmentSelectedType === LAYER_VIEW;
},
showTip() {
return (
this.showLinks &&
this.showLinksActive &&
!this.tipPreviouslyDismissed &&
!this.hoverTipDismissed
);
},
viewTypesList() {
return Object.keys(this.$options.views).map((key) => {
......@@ -77,6 +92,10 @@ export default {
},
},
methods: {
dismissTip() {
this.hoverTipDismissed = true;
this.$emit('dismissHoverTip');
},
/*
In both toggle methods, we use setTimeout so that the loading indicator displays,
then the work is done to update the DOM. The process is:
......@@ -108,33 +127,38 @@ export default {
</script>
<template>
<div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4">
<gl-loading-icon
v-if="isSwitcherLoading"
data-testid="switcher-loading-state"
class="gl-absolute gl-w-full gl-bg-white gl-opacity-5 gl-z-index-2"
size="lg"
/>
<span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span>
<gl-segmented-control
v-model="currentViewType"
:options="viewTypesList"
:disabled="isSwitcherLoading"
data-testid="pipeline-view-selector"
class="gl-mx-4"
@input="toggleView"
/>
<div v-if="showLinksToggle">
<gl-toggle
v-model="showLinksActive"
data-testid="show-links-toggle"
<div>
<div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4">
<gl-loading-icon
v-if="isSwitcherLoading"
data-testid="switcher-loading-state"
class="gl-absolute gl-w-full gl-bg-white gl-opacity-5 gl-z-index-2"
size="lg"
/>
<span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span>
<gl-segmented-control
v-model="segmentSelectedType"
:options="viewTypesList"
:disabled="isSwitcherLoading"
data-testid="pipeline-view-selector"
class="gl-mx-4"
:label="$options.i18n.linksLabelText"
:is-loading="isToggleLoading"
label-position="left"
@change="toggleShowLinksActive"
@input="toggleView"
/>
<div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
<gl-toggle
v-model="showLinksActive"
data-testid="show-links-toggle"
class="gl-mx-4"
:label="$options.i18n.linksLabelText"
:is-loading="isToggleLoading"
label-position="left"
@change="toggleShowLinksActive"
/>
</div>
</div>
<gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip">
{{ $options.i18n.hoverTipText }}
</gl-alert>
</div>
</template>
......@@ -2,7 +2,7 @@
import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
const featureName = 'pipeline_needs_banner';
......@@ -55,7 +55,7 @@ export default {
this.dismissedAlert = true;
try {
this.$apollo.mutate({
mutation: DismissPipelineNotification,
mutation: DismissPipelineGraphCallout,
variables: {
featureName,
},
......
mutation DismissPipelineNotification($featureName: String!) {
mutation DismissPipelineGraphCallout($featureName: String!) {
userCalloutCreate(input: { featureName: $featureName }) {
errors
}
......
......@@ -32983,6 +32983,9 @@ msgstr ""
msgid "Tip:"
msgstr ""
msgid "Tip: Hover over a job to see the jobs it depends on to run."
msgstr ""
msgid "Tip: add a"
msgstr ""
......
......@@ -17,7 +17,8 @@ import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import { mockPipelineResponse } from './mock_data';
import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql';
import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
const defaultProvide = {
graphqlResourceEtag: 'frog/amphibirama/etag/',
......@@ -31,15 +32,16 @@ describe('Pipeline graph wrapper', () => {
useLocalStorageSpy();
let wrapper;
const getAlert = () => wrapper.find(GlAlert);
const getAlert = () => wrapper.findComponent(GlAlert);
const getDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]');
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const getLinksLayer = () => wrapper.findComponent(LinksLayer);
const getGraph = () => wrapper.find(PipelineGraph);
const getStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]');
const getAllStageColumnGroupsInColumn = () =>
wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]');
const getViewSelector = () => wrapper.find(GraphViewSelector);
const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert);
const createComponent = ({
apolloProvider,
......@@ -62,12 +64,19 @@ describe('Pipeline graph wrapper', () => {
};
const createComponentWithApollo = ({
calloutsList = [],
data = {},
getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse),
mountFn = shallowMount,
provide = {},
} = {}) => {
const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]];
const callouts = mapCallouts(calloutsList);
const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts));
const requestHandlers = [
[getPipelineDetails, getPipelineDetailsHandler],
[getUserCallouts, getUserCalloutsHandler],
];
const apolloProvider = createMockApollo(requestHandlers);
createComponent({ apolloProvider, data, provide, mountFn });
......@@ -325,6 +334,57 @@ describe('Pipeline graph wrapper', () => {
});
});
describe('when pipelineGraphLayersView feature flag is on, layers view is selected, and links are active', () => {
beforeEach(async () => {
createComponentWithApollo({
provide: {
glFeatures: {
pipelineGraphLayersView: true,
},
},
data: {
currentViewType: LAYER_VIEW,
showLinks: true,
},
mountFn: mount,
});
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
});
it('shows the hover tip in the view selector', async () => {
await getViewSelector().setData({ showLinksActive: true });
expect(getViewSelectorTrip().exists()).toBe(true);
});
});
describe('when hover tip would otherwise show, but it has been previously dismissed', () => {
beforeEach(async () => {
createComponentWithApollo({
provide: {
glFeatures: {
pipelineGraphLayersView: true,
},
},
data: {
currentViewType: LAYER_VIEW,
showLinks: true,
},
mountFn: mount,
calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()],
});
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
});
it('does not show the hover tip', async () => {
await getViewSelector().setData({ showLinksActive: true });
expect(getViewSelectorTrip().exists()).toBe(false);
});
});
describe('when feature flag is on and local storage is set', () => {
beforeEach(async () => {
localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW);
......
import { GlLoadingIcon, GlSegmentedControl } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon, GlSegmentedControl } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue';
......@@ -12,16 +12,19 @@ describe('the graph view selector component', () => {
const findLayersViewLabel = () => findViewTypeSelector().findAll('label').at(1);
const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]');
const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon);
const findHoverTip = () => wrapper.findComponent(GlAlert);
const defaultProps = {
showLinks: false,
tipPreviouslyDismissed: false,
type: STAGE_VIEW,
};
const defaultData = {
showLinksActive: false,
hoverTipDismissed: false,
isToggleLoading: false,
isSwitcherLoading: false,
showLinksActive: false,
};
const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => {
......@@ -121,4 +124,66 @@ describe('the graph view selector component', () => {
expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]);
});
});
describe('hover tip callout', () => {
describe('when links are live and it has not been previously dismissed', () => {
beforeEach(() => {
createComponent({
props: {
showLinks: true,
},
data: {
showLinksActive: true,
},
mountFn: mount,
});
});
it('is displayed', () => {
expect(findHoverTip().exists()).toBe(true);
expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText);
});
it('emits dismissHoverTip event when the tip is dismissed', async () => {
expect(wrapper.emitted().dismissHoverTip).toBeUndefined();
await findHoverTip().find('button').trigger('click');
expect(wrapper.emitted().dismissHoverTip).toHaveLength(1);
});
});
describe('when links are live and it has been previously dismissed', () => {
beforeEach(() => {
createComponent({
props: {
showLinks: true,
tipPreviouslyDismissed: true,
},
data: {
showLinksActive: true,
},
});
});
it('is not displayed', () => {
expect(findHoverTip().exists()).toBe(false);
});
});
describe('when links are not live', () => {
beforeEach(() => {
createComponent({
props: {
showLinks: true,
},
data: {
showLinksActive: false,
},
});
});
it('is not displayed', () => {
expect(findHoverTip().exists()).toBe(false);
});
});
});
});
......@@ -669,3 +669,22 @@ export const pipelineWithUpstreamDownstream = (base) => {
return generateResponse(pip, 'root/abcd-dag');
};
export const mapCallouts = (callouts) =>
callouts.map((callout) => {
return { featureName: callout, __typename: 'UserCallout' };
});
export const mockCalloutsResponse = (mappedCallouts) => ({
data: {
currentUser: {
id: 45,
__typename: 'User',
callouts: {
id: 5,
__typename: 'UserCalloutConnection',
nodes: mappedCallouts,
},
},
},
});
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