Commit 186f2eee authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Andrew Fontaine

Add hover tip for show links toggle

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