Commit 85ba7b45 authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Phil Hughes

Add empty state to DAG graph

We add a new empty state to the graph
so that we can redirect users to the
job dependencies documentation and
educate them on this architecture.
This should only appears when there
is not enough data to render the graph
and there are no errors while parsing the
data.
parent 3ffec261
<script> <script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -25,6 +25,8 @@ export default { ...@@ -25,6 +25,8 @@ export default {
GlAlert, GlAlert,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlEmptyState,
GlButton,
}, },
props: { props: {
graphUrl: { graphUrl: {
...@@ -32,6 +34,16 @@ export default { ...@@ -32,6 +34,16 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
emptySvgPath: {
type: String,
required: true,
default: '',
},
dagDocPath: {
type: String,
required: true,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -40,6 +52,7 @@ export default { ...@@ -40,6 +52,7 @@ export default {
graphData: null, graphData: null,
showFailureAlert: false, showFailureAlert: false,
showBetaInfo: true, showBetaInfo: true,
hasNoDependentJobs: false,
}; };
}, },
errorTexts: { errorTexts: {
...@@ -48,6 +61,16 @@ export default { ...@@ -48,6 +61,16 @@ export default {
[UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'), [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'),
}, },
emptyStateTexts: {
title: __('Start using Directed Acyclic Graphs (DAG)'),
firstDescription: __(
"This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.",
),
secondDescription: __(
'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.',
),
button: __('Learn more about job dependencies'),
},
computed: { computed: {
betaMessage() { betaMessage() {
return __( return __(
...@@ -114,11 +137,18 @@ export default { ...@@ -114,11 +137,18 @@ export default {
return; return;
} }
if (parsed.links.length < 2) { if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA); this.reportFailure(UNSUPPORTED_DATA);
return; return;
} }
// If there are no links, we don't report failure
// as it simply means the user does not use job dependencies
if (parsed.links.length === 0) {
this.hasNoDependentJobs = true;
return;
}
this.graphData = parsed; this.graphData = parsed;
}, },
hideAlert() { hideAlert() {
...@@ -172,9 +202,38 @@ export default { ...@@ -172,9 +202,38 @@ export default {
<dag-graph <dag-graph
v-if="shouldDisplayGraph" v-if="shouldDisplayGraph"
:graph-data="graphData" :graph-data="graphData"
@on-failure="reportFailure" @onFailure="reportFailure"
@update-annotation="updateAnnotation" @update-annotation="updateAnnotation"
/> />
<gl-empty-state
v-else-if="hasNoDependentJobs"
:svg-path="emptySvgPath"
:title="$options.emptyStateTexts.title"
>
<template #description>
<div class="gl-text-left">
<p>
<gl-sprintf :message="$options.emptyStateTexts.firstDescription">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<p>
<gl-sprintf :message="$options.emptyStateTexts.secondDescription">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
</div>
</template>
<template #actions>
<gl-button :href="dagDocPath" target="__blank" variant="success">
{{ $options.emptyStateTexts.button }}
</gl-button>
</template>
</gl-empty-state>
</div> </div>
</div> </div>
</template> </template>
...@@ -151,7 +151,8 @@ const createDagApp = () => { ...@@ -151,7 +151,8 @@ const createDagApp = () => {
} }
const el = document.querySelector('#js-pipeline-dag-vue'); const el = document.querySelector('#js-pipeline-dag-vue');
const graphUrl = el?.dataset?.pipelineDataPath; const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset;
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -161,7 +162,9 @@ const createDagApp = () => { ...@@ -161,7 +162,9 @@ const createDagApp = () => {
render(createElement) { render(createElement) {
return createElement('dag', { return createElement('dag', {
props: { props: {
graphUrl, graphUrl: pipelineDataPath,
emptySvgPath,
dagDocPath,
}, },
}); });
}, },
......
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
- if dag_pipeline_tab_enabled - if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane #js-tab-dag.tab-pane
#js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline) } } #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
#js-tab-tests.tab-pane #js-tab-tests.tab-pane
#js-pipeline-tests-detail #js-pipeline-tests-detail
......
---
title: Resolve Add no graph empty state for DAG
merge_request: 35053
author:
type: changed
...@@ -13201,6 +13201,9 @@ msgstr "" ...@@ -13201,6 +13201,9 @@ msgstr ""
msgid "Learn more about group-level project templates" msgid "Learn more about group-level project templates"
msgstr "" msgstr ""
msgid "Learn more about job dependencies"
msgstr ""
msgid "Learn more about signing commits" msgid "Learn more about signing commits"
msgstr "" msgstr ""
...@@ -21646,6 +21649,9 @@ msgstr "" ...@@ -21646,6 +21649,9 @@ msgstr ""
msgid "Start thread & reopen %{noteable_name}" msgid "Start thread & reopen %{noteable_name}"
msgstr "" msgstr ""
msgid "Start using Directed Acyclic Graphs (DAG)"
msgstr ""
msgid "Start your Free Gold Trial" msgid "Start your Free Gold Trial"
msgstr "" msgstr ""
...@@ -23395,6 +23401,9 @@ msgstr "" ...@@ -23395,6 +23401,9 @@ msgstr ""
msgid "This page will be removed in a future release." msgid "This page will be removed in a future release."
msgstr "" msgstr ""
msgid "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph."
msgstr ""
msgid "This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}" msgid "This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}"
msgstr "" msgstr ""
...@@ -25169,6 +25178,9 @@ msgstr "" ...@@ -25169,6 +25178,9 @@ msgstr ""
msgid "UsersSelect|Unassigned" msgid "UsersSelect|Unassigned"
msgstr "" msgstr ""
msgid "Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines."
msgstr ""
msgid "Using %{code_start}::%{code_end} denotes a %{link_start}scoped label set%{link_end}" msgid "Using %{code_start}::%{code_end} denotes a %{link_start}scoped label set%{link_end}"
msgstr "" msgstr ""
......
...@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { GlAlert } from '@gitlab/ui'; import { GlAlert, GlEmptyState } from '@gitlab/ui';
import Dag from '~/pipelines/components/dag/dag.vue'; import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
...@@ -16,7 +16,14 @@ import { ...@@ -16,7 +16,14 @@ import {
LOAD_FAILURE, LOAD_FAILURE,
UNSUPPORTED_DATA, UNSUPPORTED_DATA,
} from '~/pipelines/components/dag//constants'; } from '~/pipelines/components/dag//constants';
import { mockBaseData, tooSmallGraph, unparseableGraph, singleNote, multiNote } from './mock_data'; import {
mockBaseData,
tooSmallGraph,
unparseableGraph,
graphWithoutDependencies,
singleNote,
multiNote,
} from './mock_data';
describe('Pipeline DAG graph wrapper', () => { describe('Pipeline DAG graph wrapper', () => {
let wrapper; let wrapper;
...@@ -26,6 +33,7 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -26,6 +33,7 @@ describe('Pipeline DAG graph wrapper', () => {
const getGraph = () => wrapper.find(DagGraph); const getGraph = () => wrapper.find(DagGraph);
const getNotes = () => wrapper.find(DagAnnotations); const getNotes = () => wrapper.find(DagAnnotations);
const getErrorText = type => wrapper.vm.$options.errorTexts[type]; const getErrorText = type => wrapper.vm.$options.errorTexts[type];
const getEmptyState = () => wrapper.find(GlEmptyState);
const dataPath = '/root/test/pipelines/90/dag.json'; const dataPath = '/root/test/pipelines/90/dag.json';
...@@ -35,7 +43,11 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -35,7 +43,11 @@ describe('Pipeline DAG graph wrapper', () => {
} }
wrapper = method(Dag, { wrapper = method(Dag, {
propsData, propsData: {
emptySvgPath: '/my-svg',
dagDocPath: '/my-doc',
...propsData,
},
data() { data() {
return { return {
showFailureAlert: false, showFailureAlert: false,
...@@ -64,6 +76,10 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -64,6 +76,10 @@ describe('Pipeline DAG graph wrapper', () => {
expect(getAlert().text()).toBe(getErrorText(DEFAULT)); expect(getAlert().text()).toBe(getErrorText(DEFAULT));
expect(getGraph().exists()).toBe(false); expect(getGraph().exists()).toBe(false);
}); });
it('does not render the empty state', () => {
expect(getEmptyState().exists()).toBe(false);
});
}); });
describe('when there is a dataUrl', () => { describe('when there is a dataUrl', () => {
...@@ -83,6 +99,10 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -83,6 +99,10 @@ describe('Pipeline DAG graph wrapper', () => {
expect(getGraph().exists()).toBe(false); expect(getGraph().exists()).toBe(false);
}); });
}); });
it('does not render the empty state', () => {
expect(getEmptyState().exists()).toBe(false);
});
}); });
describe('the data fetch succeeds but the parse fails', () => { describe('the data fetch succeeds but the parse fails', () => {
...@@ -101,6 +121,10 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -101,6 +121,10 @@ describe('Pipeline DAG graph wrapper', () => {
expect(getGraph().exists()).toBe(false); expect(getGraph().exists()).toBe(false);
}); });
}); });
it('does not render the empty state', () => {
expect(getEmptyState().exists()).toBe(false);
});
}); });
describe('and the data fetch and parse succeeds', () => { describe('and the data fetch and parse succeeds', () => {
...@@ -119,6 +143,15 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -119,6 +143,15 @@ describe('Pipeline DAG graph wrapper', () => {
expect(getGraph().exists()).toBe(true); expect(getGraph().exists()).toBe(true);
}); });
}); });
it('does not render the empty state', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getEmptyState().exists()).toBe(false);
});
});
}); });
describe('the data fetch and parse succeeds, but the resulting graph is too small', () => { describe('the data fetch and parse succeeds, but the resulting graph is too small', () => {
...@@ -137,6 +170,42 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -137,6 +170,42 @@ describe('Pipeline DAG graph wrapper', () => {
expect(getGraph().exists()).toBe(false); expect(getGraph().exists()).toBe(false);
}); });
}); });
it('does not show the empty dag graph state', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getEmptyState().exists()).toBe(false);
});
});
});
describe('the data fetch and parse succeeds, but the resulting graph is empty', () => {
beforeEach(() => {
mock.onGet(dataPath).replyOnce(200, graphWithoutDependencies);
createComponent({ graphUrl: dataPath }, mount);
});
it('does not render an error alert or the graph', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getAllAlerts().length).toBe(1);
expect(getAlert().text()).toContain('This feature is currently in beta.');
expect(getGraph().exists()).toBe(false);
});
});
it('shows the empty dag graph state', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getEmptyState().exists()).toBe(true);
});
});
}); });
}); });
......
...@@ -83,6 +83,46 @@ export const tooSmallGraph = { ...@@ -83,6 +83,46 @@ export const tooSmallGraph = {
], ],
}; };
export const graphWithoutDependencies = {
stages: [
{
name: 'test',
groups: [
{
name: 'jest',
size: 2,
jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }],
},
{
name: 'rspec',
size: 1,
jobs: [{ name: 'rspec' }],
},
],
},
{
name: 'fixtures',
groups: [
{
name: 'frontend fixtures',
size: 1,
jobs: [{ name: 'frontend fixtures' }],
},
],
},
{
name: 'un-needed',
groups: [
{
name: 'un-needed',
size: 1,
jobs: [{ name: 'un-needed' }],
},
],
},
],
};
export const unparseableGraph = [ export const unparseableGraph = [
{ {
name: 'test', name: 'test',
......
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