Commit da5814e1 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '298973-needs-selector-segment-and-links' into 'master'

Pipeline Graph: Update graph state selection

See merge request gitlab-org/gitlab!59685
parents 103901de c8431dcb
......@@ -25,6 +25,10 @@ export default {
type: Object,
required: true,
},
showLinks: {
type: Boolean,
required: true,
},
viewType: {
type: String,
required: true,
......@@ -91,8 +95,8 @@ export default {
collectMetrics: true,
};
},
shouldHideLinks() {
return this.isStageView;
showJobLinks() {
return !this.isStageView && this.showLinks;
},
shouldShowStageName() {
return !this.isStageView;
......@@ -188,6 +192,7 @@ export default {
:config-paths="configPaths"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:show-links="showJobLinks"
:type="$options.pipelineTypeConstants.UPSTREAM"
:view-type="viewType"
@error="onError"
......@@ -202,9 +207,8 @@ export default {
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
:metrics-config="metricsConfig"
:never-show-links="shouldHideLinks"
:show-links="showJobLinks"
:view-type="viewType"
default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
>
......@@ -234,6 +238,7 @@ export default {
:config-paths="configPaths"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:show-links="showJobLinks"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
:view-type="viewType"
@downstreamHovered="setSourceJob"
......
......@@ -48,6 +48,7 @@ export default {
pipeline: null,
pipelineLayers: null,
showAlert: false,
showLinks: false,
};
},
errorTexts: {
......@@ -182,6 +183,9 @@ export default {
}
},
/* eslint-enable @gitlab/require-i18n-strings */
updateShowLinksState(val) {
this.showLinks = val;
},
updateViewType(type) {
this.currentViewType = type;
},
......@@ -202,7 +206,9 @@ export default {
<graph-view-selector
v-if="showGraphViewSelector"
:type="currentViewType"
:show-links="showLinks"
@updateViewType="updateViewType"
@updateShowLinksState="updateShowLinksState"
/>
</local-storage-sync>
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
......@@ -211,6 +217,7 @@ export default {
:config-paths="configPaths"
:pipeline="pipeline"
:pipeline-layers="getPipelineLayers()"
:show-links="showLinks"
:view-type="currentViewType"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
......
<script>
import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
import { GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui';
import { __ } from '~/locale';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
components: {
GlDropdown,
GlDropdownItem,
GlIcon,
GlSprintf,
GlLoadingIcon,
GlSegmentedControl,
GlToggle,
},
props: {
showLinks: {
type: Boolean,
required: true,
},
type: {
type: String,
required: true,
......@@ -19,67 +22,119 @@ export default {
},
data() {
return {
currentViewType: STAGE_VIEW,
currentViewType: this.type,
showLinksActive: false,
isToggleLoading: false,
isSwitcherLoading: false,
};
},
i18n: {
labelText: __('Order jobs by'),
viewLabelText: __('Group jobs by'),
linksLabelText: __('Show dependencies'),
},
views: {
[STAGE_VIEW]: {
type: STAGE_VIEW,
text: {
primary: __('Stage'),
secondary: __('View the jobs grouped into stages'),
},
},
[LAYER_VIEW]: {
type: LAYER_VIEW,
text: {
primary: __('%{codeStart}needs:%{codeEnd} relationships'),
secondary: __('View what jobs are needed for a job to run'),
primary: __('Job dependencies'),
},
},
},
computed: {
currentDropdownText() {
return this.$options.views[this.type].text.primary;
showLinksToggle() {
return this.currentViewType === LAYER_VIEW;
},
viewTypesList() {
return Object.keys(this.$options.views).map((key) => {
return {
value: key,
text: this.$options.views[key].text.primary,
};
});
},
},
watch: {
/*
How does this reset the loading? As we note in the methods comment below,
the loader is set to on before the update work is undertaken (in the parent).
Once the work is complete, one of these values will change, since that's the
point of the work. When that happens, the related value will update and we are done.
The bonus for this approach is that it works the same whichever "direction"
the work goes in.
*/
showLinks() {
this.isToggleLoading = false;
},
type() {
this.isSwitcherLoading = false;
},
},
methods: {
itemClick(type) {
this.$emit('updateViewType', type);
/*
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:
→ user clicks
→ call stack: set loading to true
→ render: the loading icon appears on the screen
→ callback queue: now do the work to calculate the new view / links
(note: this work is done in the parent after the event is emitted)
setTimeout is how we move the work to the callback queue.
We can't use nextTick because that is called before the render loop.
See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details.
*/
toggleView(type) {
this.isSwitcherLoading = true;
setTimeout(() => {
this.$emit('updateViewType', type);
});
},
toggleShowLinksActive(val) {
this.isToggleLoading = true;
setTimeout(() => {
this.$emit('updateShowLinksState', val);
});
},
},
};
</script>
<template>
<div class="gl-display-flex gl-align-items-center gl-my-4">
<span>{{ $options.i18n.labelText }}</span>
<gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4">
<template #button-content>
<gl-sprintf :message="currentDropdownText">
<template #code="{ content }">
<code> {{ content }} </code>
</template>
</gl-sprintf>
<gl-icon class="gl-px-2" name="angle-down" :size="16" />
</template>
<gl-dropdown-item
v-for="view in $options.views"
:key="view.type"
:secondary-text="view.text.secondary"
@click="itemClick(view.type)"
>
<b>
<gl-sprintf :message="view.text.primary">
<template #code="{ content }">
<code> {{ content }} </code>
</template>
</gl-sprintf>
</b>
</gl-dropdown-item>
</gl-dropdown>
<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"
class="gl-mx-4"
:label="$options.i18n.linksLabelText"
:is-loading="isToggleLoading"
label-position="left"
@change="toggleShowLinksActive"
/>
</div>
</div>
</template>
......@@ -32,6 +32,10 @@ export default {
type: Array,
required: true,
},
showLinks: {
type: Boolean,
required: true,
},
type: {
type: String,
required: true,
......@@ -217,6 +221,7 @@ export default {
:config-paths="configPaths"
:pipeline="currentPipeline"
:pipeline-layers="getPipelineLayers(pipeline.id)"
:show-links="showLinks"
:is-linked-pipeline="true"
:view-type="viewType"
/>
......
<script>
import { GlAlert } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __ } from '~/locale';
import {
......@@ -19,10 +18,8 @@ import LinksInner from './links_inner.vue';
export default {
name: 'LinksLayer',
components: {
GlAlert,
LinksInner,
},
MAX_GROUPS: 200,
props: {
containerMeasurements: {
type: Object,
......@@ -37,10 +34,10 @@ export default {
required: false,
default: () => ({}),
},
neverShowLinks: {
showLinks: {
type: Boolean,
required: false,
default: false,
default: true,
},
},
data() {
......@@ -67,29 +64,8 @@ export default {
shouldCollectMetrics() {
return this.metricsConfig.collectMetrics && this.metricsConfig.path;
},
showAlert() {
/*
This is a hard override that allows us to turn off the links without
needing to remove the component entirely for iteration or based on graph type.
*/
if (this.neverShowLinks) {
return false;
}
return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed;
},
showLinkedLayers() {
/*
This is a hard override that allows us to turn off the links without
needing to remove the component entirely for iteration or based on graph type.
*/
if (this.neverShowLinks) {
return false;
}
return (
!this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS)
);
return this.showLinks && !this.containerZero;
},
},
errorCaptured(err, _vm, info) {
......@@ -103,7 +79,7 @@ export default {
is closed and functionality is enabled by default.
*/
if (this.neverShowLinks && !isEmpty(this.pipelineData)) {
if (!this.showLinks && !isEmpty(this.pipelineData)) {
window.requestAnimationFrame(() => {
this.prepareLinkData();
});
......@@ -151,13 +127,6 @@ export default {
reportPerformance(this.metricsConfig.path, data);
});
},
dismissAlert() {
this.alertDismissed = true;
},
overrideShowLinks() {
this.dismissAlert();
this.showLinksOverride = true;
},
prepareLinkData() {
this.beginPerfMeasure();
let numLinks;
......@@ -185,15 +154,6 @@ export default {
<slot></slot>
</links-inner>
<div v-else>
<gl-alert
v-if="showAlert"
class="gl-ml-4 gl-mb-4"
:primary-button-text="$options.i18n.showLinksAnyways"
@primaryAction="overrideShowLinks"
@dismiss="dismissAlert"
>
{{ $options.i18n.tooManyJobs }}
</gl-alert>
<div class="gl-display-flex gl-relative">
<slot></slot>
</div>
......
......@@ -416,9 +416,6 @@ msgstr ""
msgid "%{board_target} not found"
msgstr ""
msgid "%{codeStart}needs:%{codeEnd} relationships"
msgstr ""
msgid "%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements."
msgstr ""
......@@ -15217,6 +15214,9 @@ msgstr ""
msgid "Group is required when cluster_type is :group"
msgstr ""
msgid "Group jobs by"
msgstr ""
msgid "Group maintainers can register group runners in the %{link}"
msgstr ""
......@@ -18199,6 +18199,9 @@ msgstr ""
msgid "Job artifacts"
msgstr ""
msgid "Job dependencies"
msgstr ""
msgid "Job has been erased"
msgstr ""
......@@ -22505,9 +22508,6 @@ msgstr ""
msgid "Or you can choose one of the suggested colors below"
msgstr ""
msgid "Order jobs by"
msgstr ""
msgid "Orphaned member"
msgstr ""
......@@ -29054,6 +29054,9 @@ msgstr ""
msgid "Show complete raw log"
msgstr ""
msgid "Show dependencies"
msgstr ""
msgid "Show details"
msgstr ""
......@@ -34822,9 +34825,6 @@ msgstr ""
msgid "View the documentation"
msgstr ""
msgid "View the jobs grouped into stages"
msgstr ""
msgid "View the latest successful deployment to this environment"
msgstr ""
......@@ -34837,9 +34837,6 @@ msgstr ""
msgid "View users statistics"
msgstr ""
msgid "View what jobs are needed for a job to run"
msgstr ""
msgid "Viewed"
msgstr ""
......
......@@ -22,6 +22,7 @@ describe('graph component', () => {
const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
showLinks: false,
viewType: STAGE_VIEW,
configPaths: {
metricsPath: '',
......
......@@ -15,6 +15,7 @@ import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue';
import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue';
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';
......@@ -31,7 +32,9 @@ describe('Pipeline graph wrapper', () => {
let wrapper;
const getAlert = () => wrapper.find(GlAlert);
const getDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]');
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getLinksLayer = () => wrapper.findComponent(LinksLayer);
const getGraph = () => wrapper.find(PipelineGraph);
const getStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]');
const getAllStageColumnGroupsInColumn = () =>
......@@ -59,6 +62,7 @@ describe('Pipeline graph wrapper', () => {
};
const createComponentWithApollo = ({
data = {},
getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse),
mountFn = shallowMount,
provide = {},
......@@ -66,7 +70,7 @@ describe('Pipeline graph wrapper', () => {
const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]];
const apolloProvider = createMockApollo(requestHandlers);
createComponent({ apolloProvider, provide, mountFn });
createComponent({ apolloProvider, data, provide, mountFn });
};
afterEach(() => {
......@@ -74,6 +78,15 @@ describe('Pipeline graph wrapper', () => {
wrapper = null;
});
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
describe('when data is loading', () => {
it('displays the loading icon', () => {
createComponentWithApollo();
......@@ -282,6 +295,36 @@ describe('Pipeline graph wrapper', () => {
});
});
describe('when pipelineGraphLayersView feature flag is on and layers view is selected', () => {
beforeEach(async () => {
createComponentWithApollo({
provide: {
glFeatures: {
pipelineGraphLayersView: true,
},
},
data: {
currentViewType: LAYER_VIEW,
},
mountFn: mount,
});
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
});
it('sets showLinks to true', async () => {
/* This spec uses .props for performance reasons. */
expect(getLinksLayer().exists()).toBe(true);
expect(getLinksLayer().props('showLinks')).toBe(false);
expect(getViewSelector().props('type')).toBe(LAYER_VIEW);
await getDependenciesToggle().trigger('click');
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true);
});
});
describe('when feature flag is on and local storage is set', () => {
beforeEach(async () => {
localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW);
......@@ -299,10 +342,15 @@ describe('Pipeline graph wrapper', () => {
await wrapper.vm.$nextTick();
});
afterEach(() => {
localStorage.clear();
});
it('reads the view type from localStorage when available', () => {
expect(wrapper.find('[data-testid="pipeline-view-selector"] code').text()).toContain(
'needs:',
);
const viewSelectorNeedsSegment = wrapper
.findAll('[data-testid="pipeline-view-selector"] > label')
.at(1);
expect(viewSelectorNeedsSegment.classes()).toContain('active');
});
});
......
import { 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';
describe('the graph view selector component', () => {
let wrapper;
const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]');
const findViewTypeSelector = () => wrapper.findComponent(GlSegmentedControl);
const findStageViewLabel = () => findViewTypeSelector().findAll('label').at(0);
const findLayersViewLabel = () => findViewTypeSelector().findAll('label').at(1);
const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]');
const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon);
const defaultProps = {
showLinks: false,
type: STAGE_VIEW,
};
const defaultData = {
showLinksActive: false,
isToggleLoading: false,
isSwitcherLoading: false,
};
const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(GraphViewSelector, {
propsData: {
...defaultProps,
...props,
},
data() {
return {
...defaultData,
...data,
};
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when showing stage view', () => {
beforeEach(() => {
createComponent({ mountFn: mount });
});
it('shows the Stage view label as active in the selector', () => {
expect(findStageViewLabel().classes()).toContain('active');
});
it('does not show the Job dependencies (links) toggle', () => {
expect(findDependenciesToggle().exists()).toBe(false);
});
});
describe('when showing Job dependencies view', () => {
beforeEach(() => {
createComponent({
mountFn: mount,
props: {
type: LAYER_VIEW,
},
});
});
it('shows the Job dependencies view label as active in the selector', () => {
expect(findLayersViewLabel().classes()).toContain('active');
});
it('shows the Job dependencies (links) toggle', () => {
expect(findDependenciesToggle().exists()).toBe(true);
});
});
describe('events', () => {
beforeEach(() => {
jest.useFakeTimers();
createComponent({
mountFn: mount,
props: {
type: LAYER_VIEW,
},
});
});
it('shows loading state and emits updateViewType when view type toggled', async () => {
expect(wrapper.emitted().updateViewType).toBeUndefined();
expect(findSwitcherLoader().exists()).toBe(false);
await findStageViewLabel().trigger('click');
/*
Loading happens before the event is emitted or timers are run.
Then we run the timer because the event is emitted in setInterval
which is what gives the loader a chace to show up.
*/
expect(findSwitcherLoader().exists()).toBe(true);
jest.runOnlyPendingTimers();
expect(wrapper.emitted().updateViewType).toHaveLength(1);
expect(wrapper.emitted().updateViewType).toEqual([[STAGE_VIEW]]);
});
it('shows loading state and emits updateShowLinks when show links toggle is clicked', async () => {
expect(wrapper.emitted().updateShowLinksState).toBeUndefined();
expect(findToggleLoader().exists()).toBe(false);
await findDependenciesToggle().trigger('click');
/*
Loading happens before the event is emitted or timers are run.
Then we run the timer because the event is emitted in setInterval
which is what gives the loader a chace to show up.
*/
expect(findToggleLoader().exists()).toBe(true);
jest.runOnlyPendingTimers();
expect(wrapper.emitted().updateShowLinksState).toHaveLength(1);
expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]);
});
});
});
......@@ -26,6 +26,7 @@ describe('Linked Pipelines Column', () => {
const defaultProps = {
columnTitle: 'Downstream',
linkedPipelines: processedPipeline.downstream,
showLinks: false,
type: DOWNSTREAM,
viewType: STAGE_VIEW,
configPaths: {
......
import { GlAlert } from '@gitlab/ui';
import { fireEvent, within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { generateResponse, mockPipelineResponse } from '../graph/mock_data';
......@@ -8,25 +6,18 @@ import { generateResponse, mockPipelineResponse } from '../graph/mock_data';
describe('links layer component', () => {
let wrapper;
const withinComponent = () => within(wrapper.element);
const findAlert = () => wrapper.find(GlAlert);
const findShowAnyways = () =>
withinComponent().getByText(wrapper.vm.$options.i18n.showLinksAnyways);
const findLinksInner = () => wrapper.find(LinksInner);
const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo');
const containerId = `pipeline-links-container-${pipeline.id}`;
const slotContent = "<div>Ceci n'est pas un graphique</div>";
const tooManyStages = Array(101)
.fill(0)
.flatMap(() => pipeline.stages);
const defaultProps = {
containerId,
containerMeasurements: { width: 400, height: 400 },
pipelineId: pipeline.id,
pipelineData: pipeline.stages,
showLinks: false,
};
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
......@@ -49,7 +40,7 @@ describe('links layer component', () => {
wrapper = null;
});
describe('with data under max stages', () => {
describe('with show links off', () => {
beforeEach(() => {
createComponent();
});
......@@ -58,63 +49,40 @@ describe('links layer component', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('renders the inner links component', () => {
expect(findLinksInner().exists()).toBe(true);
it('does not render inner links component', () => {
expect(findLinksInner().exists()).toBe(false);
});
});
describe('with more than the max number of stages', () => {
describe('rendering', () => {
beforeEach(() => {
createComponent({ props: { pipelineData: tooManyStages } });
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('renders the alert component', () => {
expect(findAlert().exists()).toBe(true);
});
it('does not render the inner links component', () => {
expect(findLinksInner().exists()).toBe(false);
describe('with show links on', () => {
beforeEach(() => {
createComponent({
props: {
showLinks: true,
},
});
});
describe('with width or height measurement at 0', () => {
beforeEach(() => {
createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } });
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('does not render the alert component', () => {
expect(findAlert().exists()).toBe(false);
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('does not render the inner links component', () => {
expect(findLinksInner().exists()).toBe(false);
});
it('renders the inner links component', () => {
expect(findLinksInner().exists()).toBe(true);
});
});
describe('interactions', () => {
beforeEach(() => {
createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } });
});
describe('with width or height measurement at 0', () => {
beforeEach(() => {
createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } });
});
it('renders the disable button', () => {
expect(findShowAnyways()).not.toBe(null);
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('shows links when override is clicked', async () => {
expect(findLinksInner().exists()).toBe(false);
fireEvent(findShowAnyways(), new MouseEvent('click', { bubbles: true }));
await wrapper.vm.$nextTick();
expect(findLinksInner().exists()).toBe(true);
});
it('does not render the inner links component', () => {
expect(findLinksInner().exists()).toBe(false);
});
});
});
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