Commit 5c7db792 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '276949-pipeline-restructure-3' into 'master'

Pipeline Graph Restructure: New Stage Column and CSS Updates

See merge request gitlab-org/gitlab!48498
parents babfbe0f 1cbc34ef
import { REST, GRAPHQL } from './constants';
export const accessors = {
[REST]: {
groupId: 'id',
},
[GRAPHQL]: {
groupId: 'name',
},
};
......@@ -87,7 +87,7 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
@click.stop="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
......
export const DOWNSTREAM = 'downstream';
export const MAIN = 'main';
export const UPSTREAM = 'upstream';
export const REST = 'rest';
export const GRAPHQL = 'graphql';
<script>
import { escape, capitalize } from 'lodash';
import StageColumnComponent from './stage_column_component.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
import { MAIN } from './constants';
export default {
......@@ -9,7 +7,6 @@ export default {
components: {
StageColumnComponent,
},
mixins: [GraphBundleMixin],
props: {
isLinkedPipeline: {
type: Boolean,
......@@ -31,96 +28,21 @@ export default {
return this.pipeline.stages;
},
},
methods: {
capitalizeStageName(name) {
const escapedName = escape(name);
return capitalize(escapedName);
},
isFirstColumn(index) {
return index === 0;
},
stageConnectorClass(index, stage) {
let className;
// If it's the first stage column and only has one job
if (this.isFirstColumn(index) && stage.groups.length === 1) {
className = 'no-margin';
} else if (index > 0) {
// If it is not the first column
className = 'left-margin';
}
return className;
},
refreshPipelineGraph() {
this.$emit('refreshPipelineGraph');
},
/**
* CSS class is applied:
* - if pipeline graph contains only one stage column component
*
* @param {number} index
* @returns {boolean}
*/
shouldAddRightMargin(index) {
return !(index === this.graph.length - 1);
},
handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
/**
* Calculates the margin top of the clicked downstream pipeline by
* subtracting the clicked downstream pipelines offsetTop by it's parent's
* offsetTop and then subtracting 15
*/
this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
/**
* If the expanded trigger is defined and the id is different than the
* pipeline we clicked, then it means we clicked on a sibling downstream link
* and we want to reset the pipeline store. Triggering the reset without
* this condition would mean not allowing downstreams of downstreams to expand
*/
if (this.expandedDownstream?.id !== pipeline.id) {
this.$emit('onResetDownstream', this.pipeline, pipeline);
}
this.$emit('onClickDownstreamPipeline', pipeline);
},
calculateMarginTop(downstreamNode, pixelDiff) {
return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
},
hasOnlyOneJob(stage) {
return stage.groups.length === 1;
},
hasUpstreamColumn(index) {
return index === 0 && this.hasUpstream;
},
},
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
<div class="js-pipeline-graph">
<div
class="pipeline-visualization pipeline-graph"
:class="{ 'pipeline-tab-content': !isLinkedPipeline }"
class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap"
:class="{ 'gl-py-5': !isLinkedPipeline }"
>
<div>
<ul class="stage-column-list align-top">
<stage-column-component
v-for="(stage, index) in graph"
v-for="stage in graph"
:key="stage.name"
:class="{
'has-only-one-job': hasOnlyOneJob(stage),
'gl-mr-26': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:title="stage.name"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
:action="stage.status.action"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
</div>
</div>
</div>
</template>
<script>
import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
......@@ -10,7 +10,7 @@ import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
export default {
name: 'PipelineGraphLegacy',
components: {
StageColumnComponent,
StageColumnComponentLegacy,
GlLoadingIcon,
LinkedPipelinesColumn,
},
......@@ -220,7 +220,7 @@ export default {
}"
class="stage-column-list align-top"
>
<stage-column-component
<stage-column-component-legacy
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
......
......@@ -44,17 +44,19 @@ export default {
type="button"
data-toggle="dropdown"
data-display="static"
class="dropdown-menu-toggle build-content"
class="dropdown-menu-toggle build-content gl-build-content"
>
<ci-icon :status="group.status" />
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
<span class="gl-display-flex gl-align-items-center">
<ci-icon :status="group.status" :size="24" />
<span
class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"
>
<span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block">
{{ group.name }}
</span>
</span>
<span class="dropdown-counter-badge"> {{ group.size }} </span>
<span class="gl-font-weight-100 gl-font-size-lg gl-pr-2"> {{ group.size }} </span>
</div>
</button>
<ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
......
......@@ -129,19 +129,23 @@ export default {
};
</script>
<template>
<div class="ci-job-component" data-qa-selector="job_item_container">
<div
class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-qa-selector="job_item_container"
>
<gl-link
v-if="status.has_details"
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:href="status.details_path"
:title="tooltipText"
:class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item"
class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none
gl-focus-text-decoration-none"
data-testid="job-with-link"
@click.stop="hideTooltips"
@mouseout="hideTooltips"
>
<job-name-component :name="job.name" :status="job.status" />
<job-name-component :name="job.name" :status="job.status" :icon-size="24" />
</gl-link>
<div
......@@ -153,7 +157,7 @@ export default {
data-testid="job-without-link"
@mouseout="hideTooltips"
>
<job-name-component :name="job.name" :status="job.status" />
<job-name-component :name="job.name" :status="job.status" :icon-size="24" />
</div>
<action-component
......
......@@ -16,18 +16,22 @@ export default {
type: String,
required: true,
},
status: {
type: Object,
required: true,
},
iconSize: {
type: Number,
required: false,
default: 16,
},
},
};
</script>
<template>
<span class="ci-job-name-component mw-100">
<ci-icon :status="status" />
<span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom">
<span class="ci-job-name-component mw-100 gl-display-flex gl-align-items-center">
<ci-icon :size="iconSize" :status="status" />
<span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block">
{{ name }}
</span>
</span>
......
<script>
import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
import { capitalize, escape, isEmpty } from 'lodash';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import ActionComponent from './action_component.vue';
import { GRAPHQL } from './constants';
import { accessors } from './accessors';
export default {
components: {
JobItem,
JobGroupDropdown,
ActionComponent,
JobGroupDropdown,
JobItem,
MainGraphWrapper,
},
mixins: [stageColumnMixin],
props: {
title: {
type: String,
......@@ -21,16 +23,6 @@ export default {
type: Array,
required: true,
},
isFirstColumn: {
type: Boolean,
required: false,
default: false,
},
stageConnectorClass: {
type: String,
required: false,
default: '',
},
action: {
type: Object,
required: false,
......@@ -47,62 +39,67 @@ export default {
default: () => ({}),
},
},
accessors,
titleClasses: [
'gl-font-weight-bold',
'gl-pipeline-job-width',
'gl-text-truncate',
'gl-line-height-36',
'gl-pl-3',
],
computed: {
formattedTitle() {
return capitalize(escape(this.title));
},
hasAction() {
return !isEmpty(this.action);
},
},
methods: {
getAccessor(property) {
return accessors[GRAPHQL][property];
},
groupId(group) {
return `ci-badge-${escape(group.name)}`;
},
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
},
},
};
</script>
<template>
<li :class="stageConnectorClass" class="stage-column">
<div class="stage-name position-relative" data-testid="stage-column-title">
{{ title }}
<main-graph-wrapper>
<template #stages>
<div
data-testid="stage-column-title"
class="gl-display-flex gl-justify-content-space-between gl-relative"
:class="$options.titleClasses"
>
<div>{{ formattedTitle }}</div>
<action-component
v-if="hasAction"
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
class="js-stage-action stage-action rounded"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
<div class="builds-container">
<ul>
<li
v-for="(group, index) in groups"
</template>
<template #jobs>
<div
v-for="group in groups"
:id="groupId(group)"
:key="group.id"
:class="buildConnnectorClass(index)"
class="build"
:key="group[getAccessor('groupId')]"
data-testid="stage-column-group"
class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
>
<div class="curve"></div>
<job-item
v-if="group.size === 1"
:job="group.jobs[0]"
:job-hovered="jobHovered"
:pipeline-expanded="pipelineExpanded"
css-class-job-name="build-content"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
<job-group-dropdown
v-if="group.size > 1"
:group="group"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
css-class-job-name="gl-build-content"
/>
</li>
</ul>
<job-group-dropdown v-else :group="group" />
</div>
</li>
</template>
</main-graph-wrapper>
</template>
<script>
import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import ActionComponent from './action_component.vue';
export default {
components: {
JobItem,
JobGroupDropdown,
ActionComponent,
},
mixins: [stageColumnMixin],
props: {
title: {
type: String,
required: true,
},
groups: {
type: Array,
required: true,
},
isFirstColumn: {
type: Boolean,
required: false,
default: false,
},
stageConnectorClass: {
type: String,
required: false,
default: '',
},
action: {
type: Object,
required: false,
default: () => ({}),
},
jobHovered: {
type: String,
required: false,
default: '',
},
pipelineExpanded: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
hasAction() {
return !isEmpty(this.action);
},
},
methods: {
groupId(group) {
return `ci-badge-${escape(group.name)}`;
},
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
},
},
};
</script>
<template>
<li :class="stageConnectorClass" class="stage-column">
<div class="stage-name position-relative" data-testid="stage-column-title">
{{ title }}
<action-component
v-if="hasAction"
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
class="js-stage-action stage-action rounded"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
<div class="builds-container">
<ul>
<li
v-for="(group, index) in groups"
:id="groupId(group)"
:key="group.id"
:class="buildConnnectorClass(index)"
class="build"
>
<div class="curve"></div>
<job-item
v-if="group.size === 1"
:job="group.jobs[0]"
:job-hovered="jobHovered"
:pipeline-expanded="pipelineExpanded"
css-class-job-name="build-content"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
<job-group-dropdown
v-if="group.size > 1"
:group="group"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</li>
</ul>
</div>
</li>
</template>
<script>
export default {
props: {
stageClasses: {
type: String,
required: false,
default: '',
},
jobClasses: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div>
<div
class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-py-4 gl-mb-5"
:class="stageClasses"
>
<slot name="stages"> </slot>
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
:class="jobClasses"
>
<slot name="jobs"> </slot>
</div>
</div>
</template>
......@@ -129,6 +129,51 @@
overflow: auto;
}
// Move to Gitlab UI
.gl-font-weight-100 {
font-weight: 100;
}
.gl-active-text-decoration-none:active,
.gl-focus-text-decoration-none:focus {
text-decoration: none;
}
// These are single-value classes to use with utility-class style CSS
// but to still access this variable. Do not add other styles.
.gl-pipeline-min-h {
min-height: $dropdown-max-height-lg;
}
.gl-pipeline-job-width {
width: 186px;
}
.gl-pipeline-title-width {
width: 176px;
}
.gl-build-content {
@include build-content();
}
.gl-ci-action-icon-container {
position: absolute;
right: 5px;
top: 50% !important;
transform: translateY(-50%);
// Action Icons in big pipeline-graph nodes
&.ci-action-icon-wrapper {
height: 30px;
width: 30px;
border-radius: 100%;
display: block;
padding: 0;
line-height: 0;
}
}
// Pipeline graph, used at
// app/assets/javascripts/pipelines/components/graph/graph_component.vue
.pipeline-graph {
......
......@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import { setHTMLFixture } from 'helpers/fixtures';
import PipelineStore from '~/pipelines/stores/pipeline_store';
import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue';
import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import graphJSON from './mock_data_legacy';
import linkedPipelineJSON from './linked_pipelines_mock_data';
......@@ -16,7 +16,7 @@ describe('graph component', () => {
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy);
const findStageColumnAt = i => findStageColumns().at(i);
beforeEach(() => {
......
import { shallowMount } from '@vue/test-utils';
import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue';
describe('stage column component', () => {
const mockJob = {
id: 4250,
name: 'test',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4250',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4250/retry',
method: 'post',
},
},
};
let wrapper;
beforeEach(() => {
const mockGroups = [];
for (let i = 0; i < 3; i += 1) {
const mockedJob = { ...mockJob };
mockedJob.id += i;
mockGroups.push(mockedJob);
}
wrapper = shallowMount(StageColumnComponentLegacy, {
propsData: {
title: 'foo',
groups: mockGroups,
hasTriggeredBy: false,
},
});
});
it('should render provided title', () => {
expect(
wrapper
.find('.stage-name')
.text()
.trim(),
).toBe('foo');
});
it('should render the provided groups', () => {
expect(wrapper.findAll('.builds-container > ul > li').length).toBe(
wrapper.props('groups').length,
);
});
describe('jobId', () => {
it('escapes job name', () => {
wrapper = shallowMount(StageColumnComponentLegacy, {
propsData: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
},
},
],
title: 'test',
hasTriggeredBy: false,
},
});
expect(wrapper.find('.builds-container li').attributes('id')).toBe(
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
);
});
});
describe('with action', () => {
it('renders action button', () => {
wrapper = shallowMount(StageColumnComponentLegacy, {
propsData: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
},
},
],
title: 'test',
hasTriggeredBy: false,
action: {
icon: 'play',
title: 'Play all',
path: 'action',
},
},
});
expect(wrapper.find('.js-stage-action').exists()).toBe(true);
});
});
describe('without action', () => {
it('does not render action button', () => {
wrapper = shallowMount(StageColumnComponentLegacy, {
propsData: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
},
},
],
title: 'test',
hasTriggeredBy: false,
},
});
expect(wrapper.find('.js-stage-action').exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import ActionComponent from '~/pipelines/components/graph/action_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
describe('stage column component', () => {
const mockJob = {
const mockJob = {
id: 4250,
name: 'test',
status: {
......@@ -19,46 +18,60 @@ describe('stage column component', () => {
method: 'post',
},
},
};
};
const mockGroups = Array(4)
.fill(0)
.map((item, idx) => {
return { ...mockJob, id: idx, name: `fish-${idx}` };
});
const defaultProps = {
title: 'Fish',
groups: mockGroups,
};
describe('stage column component', () => {
let wrapper;
beforeEach(() => {
const mockGroups = [];
for (let i = 0; i < 3; i += 1) {
const mockedJob = { ...mockJob };
mockedJob.id += i;
mockGroups.push(mockedJob);
}
wrapper = shallowMount(stageColumnComponent, {
const findStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]');
const findStageColumnGroup = () => wrapper.find('[data-testid="stage-column-group"]');
const findAllStageColumnGroups = () => wrapper.findAll('[data-testid="stage-column-group"]');
const findActionComponent = () => wrapper.find(ActionComponent);
const createComponent = ({ method = shallowMount, props = {} } = {}) => {
wrapper = method(StageColumnComponent, {
propsData: {
title: 'foo',
groups: mockGroups,
hasTriggeredBy: false,
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when mounted', () => {
beforeEach(() => {
createComponent({ method: mount });
});
it('should render provided title', () => {
expect(
wrapper
.find('.stage-name')
.text()
.trim(),
).toBe('foo');
expect(findStageColumnTitle().text()).toBe(defaultProps.title);
});
it('should render the provided groups', () => {
expect(wrapper.findAll('.builds-container > ul > li').length).toBe(
wrapper.props('groups').length,
);
expect(findAllStageColumnGroups().length).toBe(mockGroups.length);
});
});
describe('jobId', () => {
it('escapes job name', () => {
wrapper = shallowMount(stageColumnComponent, {
propsData: {
describe('job', () => {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
......@@ -70,21 +83,29 @@ describe('stage column component', () => {
},
},
],
title: 'test',
hasTriggeredBy: false,
title: 'test <img src=x onerror=alert(document.domain)>',
},
});
});
expect(wrapper.find('.builds-container li').attributes('id')).toBe(
it('capitalizes and escapes name', () => {
expect(findStageColumnTitle().text()).toBe(
'Test &lt;img src=x onerror=alert(document.domain)&gt;',
);
});
it('escapes id', () => {
expect(findStageColumnGroup().attributes('id')).toBe(
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
);
});
});
describe('with action', () => {
it('renders action button', () => {
wrapper = shallowMount(stageColumnComponent, {
propsData: {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
......@@ -105,15 +126,18 @@ describe('stage column component', () => {
},
},
});
});
expect(wrapper.find('.js-stage-action').exists()).toBe(true);
it('renders action button', () => {
expect(findActionComponent().exists()).toBe(true);
});
});
describe('without action', () => {
it('does not render action button', () => {
wrapper = shallowMount(stageColumnComponent, {
propsData: {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
......@@ -129,8 +153,10 @@ describe('stage column component', () => {
hasTriggeredBy: false,
},
});
});
expect(wrapper.find('.js-stage-action').exists()).toBe(false);
it('does not render action button', () => {
expect(findActionComponent().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