Commit 773c29ee authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Andrew Fontaine

Add annotation component

Adds and integrated component
Fixes interaction bug on nodes
parent db66b4e5
......@@ -8,3 +8,8 @@ export const DEFAULT = 'default';
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
export const NODE_SELECTOR = 'dag-node';
/* Annotation types */
export const ADD_NOTE = 'add';
export const REMOVE_NOTE = 'remove';
export const REPLACE_NOTES = 'replace';
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import DagGraph from './dag_graph.vue';
import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants';
import DagAnnotations from './dag_annotations.vue';
import {
DEFAULT,
PARSE_FAILURE,
LOAD_FAILURE,
UNSUPPORTED_DATA,
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
} from './constants';
import { parseData } from './parsing_utils';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Dag',
components: {
DagAnnotations,
DagGraph,
GlAlert,
GlLink,
......@@ -24,10 +35,11 @@ export default {
},
data() {
return {
showFailureAlert: false,
showBetaInfo: true,
annotationsMap: {},
failureType: null,
graphData: null,
showFailureAlert: false,
showBetaInfo: true,
};
},
errorTexts: {
......@@ -66,6 +78,9 @@ export default {
};
}
},
shouldDisplayAnnotations() {
return !isEmpty(this.annotationsMap);
},
shouldDisplayGraph() {
return Boolean(!this.showFailureAlert && this.graphData);
},
......@@ -86,6 +101,9 @@ export default {
.catch(() => reportFailure(LOAD_FAILURE));
},
methods: {
addAnnotationToMap({ uid, source, target }) {
this.$set(this.annotationsMap, uid, { source, target });
},
processGraphData(data) {
let parsed;
......@@ -109,10 +127,28 @@ export default {
hideBetaInfo() {
this.showBetaInfo = false;
},
removeAnnotationFromMap({ uid }) {
this.$delete(this.annotationsMap, uid);
},
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
updateAnnotation({ type, data }) {
switch (type) {
case ADD_NOTE:
this.addAnnotationToMap(data);
break;
case REMOVE_NOTE:
this.removeAnnotationFromMap(data);
break;
case REPLACE_NOTES:
this.annotationsMap = data;
break;
default:
break;
}
},
},
};
</script>
......@@ -131,6 +167,14 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
<dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
<div class="gl-relative">
<dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
<dag-graph
v-if="shouldDisplayGraph"
:graph-data="graphData"
@on-failure="reportFailure"
@update-annotation="updateAnnotation"
/>
</div>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
name: 'DagAnnotations',
components: {
GlButton,
},
props: {
annotations: {
type: Object,
required: true,
},
},
data() {
return {
showList: true,
};
},
computed: {
linkText() {
return this.showList ? __('Hide list') : __('Show list');
},
shouldShowLink() {
return Object.keys(this.annotations).length > 1;
},
wrapperClasses() {
return [
'gl-display-flex',
'gl-flex-direction-column',
'gl-absolute',
'gl-right-1',
'gl-top-0',
'gl-w-max-content',
'gl-px-5',
'gl-py-4',
'gl-rounded-base',
'gl-bg-white',
].join(' ');
},
},
methods: {
toggleList() {
this.showList = !this.showList;
},
},
};
</script>
<template>
<div :class="wrapperClasses">
<div v-if="showList">
<div
v-for="note in annotations"
:key="note.uid"
class="gl-display-flex gl-align-items-center"
>
<div
data-testid="dag-color-block"
class="gl-w-6 gl-h-5"
:style="{
background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`,
}"
></div>
<div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center">
{{ note.source.name }}{{ note.target.name }}
</div>
</div>
</div>
<gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button>
</div>
</template>
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants';
import {
LINK_SELECTOR,
NODE_SELECTOR,
PARSE_FAILURE,
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
} from './constants';
import {
currentIsLive,
getLiveLinksAsDict,
highlightLinks,
restoreLinks,
toggleLinkHighlight,
......@@ -55,8 +64,8 @@ export default {
data() {
return {
color: () => {},
width: 0,
height: 0,
width: 0,
};
},
mounted() {
......@@ -65,7 +74,7 @@ export default {
try {
countedAndTransformed = this.transformData(this.graphData);
} catch {
this.$emit('onFailure', PARSE_FAILURE);
this.$emit('on-failure', PARSE_FAILURE);
return;
}
......@@ -95,17 +104,33 @@ export default {
},
appendLinkInteractions(link) {
const { baseOpacity } = this.$options.viewOptions;
return link
.on('mouseover', highlightLinks)
.on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity))
.on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity));
.on('mouseover', (d, idx, collection) => {
if (currentIsLive(idx, collection)) {
return;
}
this.$emit('update-annotation', { type: ADD_NOTE, data: d });
highlightLinks(d, idx, collection);
})
.on('mouseout', (d, idx, collection) => {
if (currentIsLive(idx, collection)) {
return;
}
this.$emit('update-annotation', { type: REMOVE_NOTE, data: d });
restoreLinks(baseOpacity);
})
.on('click', (d, idx, collection) => {
toggleLinkHighlight(baseOpacity, d, idx, collection);
this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() });
});
},
appendNodeInteractions(node) {
return node.on(
'click',
togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity),
);
return node.on('click', (d, idx, collection) => {
togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection);
this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() });
});
},
appendLabelAsForeignObject(d, i, n) {
......@@ -271,6 +296,11 @@ export default {
.attr('y2', d => d.y1 - 4);
},
initColors() {
const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
return ({ name }) => colorFn(name);
},
labelNodes(svg, nodeData) {
return svg
.append('g')
......@@ -282,11 +312,6 @@ export default {
.each(this.appendLabelAsForeignObject);
},
initColors() {
const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
return ({ name }) => colorFn(name);
},
transformData(parsed) {
const baseLayout = createSankey()(parsed);
const cleanedNodes = removeOrphanNodes(baseLayout.nodes);
......
......@@ -5,10 +5,20 @@ export const highlightIn = 1;
export const highlightOut = 0.2;
const getCurrent = (idx, collection) => d3.select(collection[idx]);
const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED);
const getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`);
const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
export const getLiveLinksAsDict = () => {
return Object.fromEntries(
getLiveLinks()
.data()
.map(d => [d.uid, d]),
);
};
export const currentIsLive = (idx, collection) =>
getCurrent(idx, collection).classed(IS_HIGHLIGHTED);
const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut);
const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2');
const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn);
......@@ -16,10 +26,10 @@ const foregroundNodes = selection => selection.attr('stroke', d => d.color);
const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity);
const renewNodes = selection => selection.attr('stroke', d => d.color);
const getAllLinkAncestors = node => {
export const getAllLinkAncestors = node => {
if (node.targetLinks) {
return node.targetLinks.flatMap(n => {
return [n.uid, ...getAllLinkAncestors(n.source)];
return [n, ...getAllLinkAncestors(n.source)];
});
}
......@@ -59,8 +69,8 @@ const highlightPath = (parentLinks, parentNodes) => {
backgroundNodes(getNodesNotLive());
/* highlight correct links */
parentLinks.forEach(id => {
foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
parentLinks.forEach(({ uid }) => {
foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true);
});
/* highlight correct nodes */
......@@ -69,9 +79,22 @@ const highlightPath = (parentLinks, parentNodes) => {
});
};
const restoreNodes = () => {
/*
When paths are unclicked, they can take down nodes that
are still in use for other paths. This checks the live paths and
rehighlights their nodes.
*/
getLiveLinks().each(d => {
foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true);
foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true);
});
};
const restorePath = (parentLinks, parentNodes, baseOpacity) => {
parentLinks.forEach(id => {
renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
parentLinks.forEach(({ uid }) => {
renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
});
parentNodes.forEach(id => {
......@@ -86,14 +109,10 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => {
backgroundLinks(getOtherLinks());
backgroundNodes(getNodesNotLive());
restoreNodes();
};
export const restoreLinks = (baseOpacity, d, idx, collection) => {
/* in this case, it has just been clicked */
if (currentIsLive(idx, collection)) {
return;
}
export const restoreLinks = baseOpacity => {
/*
if there exist live links, reset to highlight out / pale
otherwise, reset to base
......@@ -111,11 +130,12 @@ export const restoreLinks = (baseOpacity, d, idx, collection) => {
export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => {
if (currentIsLive(idx, collection)) {
restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity);
restorePath([d], [d.source.uid, d.target.uid], baseOpacity);
restoreNodes();
return;
}
highlightPath([d.uid], [d.source.uid, d.target.uid]);
highlightPath([d], [d.source.uid, d.target.uid]);
};
export const togglePathHighlights = (baseOpacity, d, idx, collection) => {
......
---
title: Add annotation component for DAG
merge_request: 35240
author:
type: added
......@@ -11709,6 +11709,9 @@ msgstr ""
msgid "Hide host keys manual input"
msgstr ""
msgid "Hide list"
msgstr ""
msgid "Hide marketing-related entries from help"
msgstr ""
......@@ -20783,6 +20786,9 @@ msgstr ""
msgid "Show latest version"
msgstr ""
msgid "Show list"
msgstr ""
msgid "Show me everything"
msgstr ""
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
import { singleNote, multiNote } from './mock_data';
describe('The DAG annotations', () => {
let wrapper;
const getColorBlock = () => wrapper.find('[data-testid="dag-color-block"]');
const getAllColorBlocks = () => wrapper.findAll('[data-testid="dag-color-block"]');
const getTextBlock = () => wrapper.find('[data-testid="dag-note-text"]');
const getAllTextBlocks = () => wrapper.findAll('[data-testid="dag-note-text"]');
const getToggleButton = () => wrapper.find(GlButton);
const createComponent = (propsData = {}, method = shallowMount) => {
if (wrapper?.destroy) {
wrapper.destroy();
}
wrapper = method(DagAnnotations, {
propsData,
data() {
return {
showList: true,
};
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when there is one annotation', () => {
const currentNote = singleNote['dag-link103'];
beforeEach(() => {
createComponent({ annotations: singleNote });
});
it('displays the color block', () => {
expect(getColorBlock().exists()).toBe(true);
});
it('displays the text block', () => {
expect(getTextBlock().exists()).toBe(true);
expect(getTextBlock().text()).toBe(`${currentNote.source.name}${currentNote.target.name}`);
});
it('does not display the list toggle link', () => {
expect(getToggleButton().exists()).toBe(false);
});
});
describe('when there are multiple annoataions', () => {
beforeEach(() => {
createComponent({ annotations: multiNote });
});
it('displays a color block for each link', () => {
expect(getAllColorBlocks().length).toBe(Object.keys(multiNote).length);
});
it('displays a text block for each link', () => {
expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length);
Object.values(multiNote).forEach((item, idx) => {
expect(
getAllTextBlocks()
.at(idx)
.text(),
).toBe(`${item.source.name}${item.target.name}`);
});
});
it('displays the list toggle link', () => {
expect(getToggleButton().exists()).toBe(true);
expect(getToggleButton().text()).toBe('Hide list');
});
});
describe('the list toggle', () => {
beforeEach(() => {
createComponent({ annotations: multiNote }, mount);
});
describe('clicking hide', () => {
it('hides listed items and changes text to show', () => {
expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length);
expect(getToggleButton().text()).toBe('Hide list');
getToggleButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(getAllTextBlocks().length).toBe(0);
expect(getToggleButton().text()).toBe('Show list');
});
});
});
describe('clicking show', () => {
it('shows listed items and changes text to hide', () => {
getToggleButton().trigger('click');
getToggleButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length);
expect(getToggleButton().text()).toBe('Hide list');
});
});
});
});
});
import { mount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants';
import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions';
......@@ -19,7 +19,7 @@ describe('The DAG graph', () => {
wrapper.destroy();
}
wrapper = mount(DagGraph, {
wrapper = shallowMount(DagGraph, {
attachToDocument: true,
propsData,
data() {
......
......@@ -5,14 +5,18 @@ import waitForPromises from 'helpers/wait_for_promises';
import { GlAlert } from '@gitlab/ui';
import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
import {
ADD_NOTE,
REMOVE_NOTE,
REPLACE_NOTES,
DEFAULT,
PARSE_FAILURE,
LOAD_FAILURE,
UNSUPPORTED_DATA,
} from '~/pipelines/components/dag//constants';
import { mockBaseData, tooSmallGraph, unparseableGraph } from './mock_data';
import { mockBaseData, tooSmallGraph, unparseableGraph, singleNote, multiNote } from './mock_data';
describe('Pipeline DAG graph wrapper', () => {
let wrapper;
......@@ -20,6 +24,7 @@ describe('Pipeline DAG graph wrapper', () => {
const getAlert = () => wrapper.find(GlAlert);
const getAllAlerts = () => wrapper.findAll(GlAlert);
const getGraph = () => wrapper.find(DagGraph);
const getNotes = () => wrapper.find(DagAnnotations);
const getErrorText = type => wrapper.vm.$options.errorTexts[type];
const dataPath = '/root/test/pipelines/90/dag.json';
......@@ -134,4 +139,53 @@ describe('Pipeline DAG graph wrapper', () => {
});
});
});
describe('annotations', () => {
beforeEach(() => {
mock.onGet(dataPath).replyOnce(200, mockBaseData);
createComponent({ graphUrl: dataPath }, mount);
});
it('toggles on link mouseover and mouseout', () => {
const currentNote = singleNote['dag-link103'];
expect(getNotes().exists()).toBe(false);
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
getGraph().vm.$emit('update-annotation', { type: ADD_NOTE, data: currentNote });
return wrapper.vm.$nextTick();
})
.then(() => {
expect(getNotes().exists()).toBe(true);
getGraph().vm.$emit('update-annotation', { type: REMOVE_NOTE, data: currentNote });
return wrapper.vm.$nextTick();
})
.then(() => {
expect(getNotes().exists()).toBe(false);
});
});
it('toggles on node and link click', () => {
expect(getNotes().exists()).toBe(false);
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: multiNote });
return wrapper.vm.$nextTick();
})
.then(() => {
expect(getNotes().exists()).toBe(true);
getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: {} });
return wrapper.vm.$nextTick();
})
.then(() => {
expect(getNotes().exists()).toBe(false);
});
});
});
});
......@@ -388,3 +388,43 @@ export const parsedData = {
},
],
};
export const singleNote = {
'dag-link103': {
uid: 'dag-link103',
source: {
name: 'canary_a',
color: '#b31756',
},
target: {
name: 'production_a',
color: '#b24800',
},
},
};
export const multiNote = {
...singleNote,
'dag-link104': {
uid: 'dag-link104',
source: {
name: 'build_a',
color: '#e17223',
},
target: {
name: 'test_c',
color: '#006887',
},
},
'dag-link105': {
uid: 'dag-link105',
source: {
name: 'test_c',
color: '#006887',
},
target: {
name: 'post_test_c',
color: '#3547de',
},
},
};
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