Commit f3af6a58 authored by David Pisek's avatar David Pisek Committed by Natalia Tepluhina

Add pipeline information to dependencies header

This commit adds additional information (link to latest pipeline
and it's time) to the header section of the dependency list page.

It also increases the main headers font size adds a link to the
relevant documentation page to it.
parent afb3cff3
---
title: Add pipeline information to dependency list header
merge_request: 19352
author:
type: added
...@@ -17,7 +17,7 @@ sidebar. ...@@ -17,7 +17,7 @@ sidebar.
## Viewing dependencies ## Viewing dependencies
![Dependency List](img/dependency_list_v12_3.png) ![Dependency List](img/dependency_list_v12_4.png)
Dependencies are displayed with the following information: Dependencies are displayed with the following information:
......
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlTabs, GlLink } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import DependenciesActions from './dependencies_actions.vue'; import DependenciesActions from './dependencies_actions.vue';
import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue'; import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue'; import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue';
...@@ -16,8 +18,10 @@ export default { ...@@ -16,8 +18,10 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlTab, GlTab,
GlTabs, GlTabs,
GlLink,
DependencyListIncompleteAlert, DependencyListIncompleteAlert,
DependencyListJobFailedAlert, DependencyListJobFailedAlert,
Icon,
PaginatedDependenciesTable, PaginatedDependenciesTable,
}, },
props: { props: {
...@@ -43,6 +47,7 @@ export default { ...@@ -43,6 +47,7 @@ export default {
computed: { computed: {
...mapState(['currentList', 'listTypes']), ...mapState(['currentList', 'listTypes']),
...mapGetters([ ...mapGetters([
'generatedAtTimeAgo',
'isInitialized', 'isInitialized',
'isJobNotSetUp', 'isJobNotSetUp',
'isJobFailed', 'isJobFailed',
...@@ -60,6 +65,18 @@ export default { ...@@ -60,6 +65,18 @@ export default {
this.setCurrentList(namespace); this.setCurrentList(namespace);
}, },
}, },
subHeadingText() {
const { jobPath } = this.reportInfo;
const body = __(
'Displays dependencies and known vulnerabilities, based on the %{linkStart}latest pipeline%{linkEnd} scan',
);
const linkStart = jobPath ? `<a href="${jobPath}">` : '';
const linkEnd = jobPath ? '</a>' : '';
return sprintf(body, { linkStart, linkEnd }, false);
},
}, },
created() { created() {
this.setDependenciesEndpoint(this.endpoint); this.setDependenciesEndpoint(this.endpoint);
...@@ -97,7 +114,7 @@ export default { ...@@ -97,7 +114,7 @@ export default {
:primary-button-text="__('Learn more about the dependency list')" :primary-button-text="__('Learn more about the dependency list')"
/> />
<div v-else> <section v-else>
<dependency-list-incomplete-alert <dependency-list-incomplete-alert
v-if="isIncomplete && !isIncompleteAlertDismissed" v-if="isIncomplete && !isIncompleteAlertDismissed"
@close="dismissIncompleteListAlert" @close="dismissIncompleteListAlert"
...@@ -109,7 +126,24 @@ export default { ...@@ -109,7 +126,24 @@ export default {
@close="dismissJobFailedAlert" @close="dismissJobFailedAlert"
/> />
<h3 class="h5">{{ __('Dependencies') }}</h3> <header class="my-3">
<h2 class="h4 mb-1">
{{ __('Dependencies') }}
<gl-link
target="_blank"
:href="documentationPath"
:aria-label="__('Dependencies help page link')"
><icon name="question"
/></gl-link>
</h2>
<p class="mb-0">
<span v-html="subHeadingText"></span>
<span v-if="generatedAtTimeAgo"
><span aria-hidden="true">&bull;</span>
<span class="text-secondary"> {{ generatedAtTimeAgo }}</span></span
>
</p>
</header>
<gl-tabs v-model="currentListIndex" content-class="pt-0"> <gl-tabs v-model="currentListIndex" content-class="pt-0">
<gl-tab <gl-tab
...@@ -131,5 +165,5 @@ export default { ...@@ -131,5 +165,5 @@ export default {
</li> </li>
</template> </template>
</gl-tabs> </gl-tabs>
</div> </section>
</template> </template>
export const isInitialized = ({ currentList, ...state }) => state[currentList].initialized; export const isInitialized = ({ currentList, ...state }) => state[currentList].initialized;
export const reportInfo = ({ currentList, ...state }) => state[currentList].reportInfo; export const reportInfo = ({ currentList, ...state }) => state[currentList].reportInfo;
export const generatedAtTimeAgo = ({ currentList }, getters) =>
getters[`${currentList}/generatedAtTimeAgo`];
export const isJobNotSetUp = ({ currentList }, getters) => getters[`${currentList}/isJobNotSetUp`]; export const isJobNotSetUp = ({ currentList }, getters) => getters[`${currentList}/isJobNotSetUp`];
export const isJobFailed = ({ currentList }, getters) => getters[`${currentList}/isJobFailed`]; export const isJobFailed = ({ currentList }, getters) => getters[`${currentList}/isJobFailed`];
export const isIncomplete = ({ currentList }, getters) => getters[`${currentList}/isIncomplete`]; export const isIncomplete = ({ currentList }, getters) => getters[`${currentList}/isIncomplete`];
......
import { REPORT_STATUS } from './constants'; import { REPORT_STATUS } from './constants';
import { getTimeago } from '~/lib/utils/datetime_utility';
export const generatedAtTimeAgo = ({ reportInfo: { generatedAt } }) =>
generatedAt ? getTimeago().format(generatedAt) : '';
export const isJobNotSetUp = state => state.reportInfo.status === REPORT_STATUS.jobNotSetUp; export const isJobNotSetUp = state => state.reportInfo.status === REPORT_STATUS.jobNotSetUp;
export const isJobFailed = state => export const isJobFailed = state =>
......
...@@ -19,6 +19,7 @@ export default { ...@@ -19,6 +19,7 @@ export default {
state.errorLoading = false; state.errorLoading = false;
state.reportInfo.status = reportInfo.status; state.reportInfo.status = reportInfo.status;
state.reportInfo.jobPath = reportInfo.job_path; state.reportInfo.jobPath = reportInfo.job_path;
state.reportInfo.generatedAt = reportInfo.generated_at;
state.initialized = true; state.initialized = true;
}, },
[types.RECEIVE_DEPENDENCIES_ERROR](state) { [types.RECEIVE_DEPENDENCIES_ERROR](state) {
...@@ -29,6 +30,7 @@ export default { ...@@ -29,6 +30,7 @@ export default {
state.reportInfo = { state.reportInfo = {
status: REPORT_STATUS.ok, status: REPORT_STATUS.ok,
jobPath: '', jobPath: '',
generatedAt: '',
}; };
state.initialized = true; state.initialized = true;
}, },
......
...@@ -12,6 +12,7 @@ export default () => ({ ...@@ -12,6 +12,7 @@ export default () => ({
reportInfo: { reportInfo: {
status: REPORT_STATUS.ok, status: REPORT_STATUS.ok,
jobPath: '', jobPath: '',
generatedAt: '',
}, },
filter: FILTER.all, filter: FILTER.all,
sortField: 'name', sortField: 'name',
......
import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlLink } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import createStore from 'ee/dependencies/store'; import createStore from 'ee/dependencies/store';
import { addListType } from 'ee/dependencies/store/utils'; import { addListType } from 'ee/dependencies/store/utils';
...@@ -64,6 +65,8 @@ describe('DependenciesApp component', () => { ...@@ -64,6 +65,8 @@ describe('DependenciesApp component', () => {
}); });
store.state[namespace].pageInfo.total = total; store.state[namespace].pageInfo.total = total;
store.state[namespace].reportInfo.status = REPORT_STATUS.ok; store.state[namespace].reportInfo.status = REPORT_STATUS.ok;
store.state[namespace].reportInfo.generatedAt = getDateInPast(new Date(), 7);
store.state[namespace].reportInfo.jobPath = '/jobs/foo/321';
}); });
}; };
...@@ -95,6 +98,10 @@ describe('DependenciesApp component', () => { ...@@ -95,6 +98,10 @@ describe('DependenciesApp component', () => {
const findVulnerableTabControl = () => findTabControls().at(1); const findVulnerableTabControl = () => findTabControls().at(1);
const findVulnerableTabComponent = () => wrapper.findAll(GlTab).at(1); const findVulnerableTabComponent = () => wrapper.findAll(GlTab).at(1);
const findHeader = () => wrapper.find('section > header');
const findHeaderHelpLink = () => findHeader().find(GlLink);
const findHeaderJobLink = () => findHeader().find('a');
const expectComponentWithProps = (Component, props = {}) => { const expectComponentWithProps = (Component, props = {}) => {
const componentWrapper = wrapper.find(Component); const componentWrapper = wrapper.find(Component);
expect(componentWrapper.isVisible()).toBe(true); expect(componentWrapper.isVisible()).toBe(true);
...@@ -102,6 +109,7 @@ describe('DependenciesApp component', () => { ...@@ -102,6 +109,7 @@ describe('DependenciesApp component', () => {
}; };
const expectNoDependenciesTables = () => expect(findDependenciesTables()).toHaveLength(0); const expectNoDependenciesTables = () => expect(findDependenciesTables()).toHaveLength(0);
const expectNoHeader = () => expect(findHeader().exists()).toBe(false);
const expectDependenciesTables = () => { const expectDependenciesTables = () => {
const { wrappers } = findDependenciesTables(); const { wrappers } = findDependenciesTables();
...@@ -110,6 +118,10 @@ describe('DependenciesApp component', () => { ...@@ -110,6 +118,10 @@ describe('DependenciesApp component', () => {
expect(wrappers[1].props()).toEqual({ namespace: vulnerableNamespace }); expect(wrappers[1].props()).toEqual({ namespace: vulnerableNamespace });
}; };
const expectHeader = () => {
expect(findHeader().exists()).toBe(true);
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -128,6 +140,7 @@ describe('DependenciesApp component', () => { ...@@ -128,6 +140,7 @@ describe('DependenciesApp component', () => {
it('shows only the loading icon', () => { it('shows only the loading icon', () => {
expectComponentWithProps(GlLoadingIcon); expectComponentWithProps(GlLoadingIcon);
expectNoHeader();
expectNoDependenciesTables(); expectNoDependenciesTables();
}); });
...@@ -140,6 +153,7 @@ describe('DependenciesApp component', () => { ...@@ -140,6 +153,7 @@ describe('DependenciesApp component', () => {
it('shows only the empty state', () => { it('shows only the empty state', () => {
expectComponentWithProps(GlEmptyState, { svgPath: basicAppProps.emptyStateSvgPath }); expectComponentWithProps(GlEmptyState, { svgPath: basicAppProps.emptyStateSvgPath });
expectNoHeader();
expectNoDependenciesTables(); expectNoDependenciesTables();
}); });
}); });
...@@ -152,9 +166,22 @@ describe('DependenciesApp component', () => { ...@@ -152,9 +166,22 @@ describe('DependenciesApp component', () => {
}); });
it('shows both dependencies tables with the correct props', () => { it('shows both dependencies tables with the correct props', () => {
expectHeader();
expectDependenciesTables(); expectDependenciesTables();
}); });
it('shows a link to the latest job', () => {
expect(findHeaderJobLink().attributes('href')).toBe('/jobs/foo/321');
});
it('shows when the last job ran', () => {
expect(findHeader().text()).toContain('1 week ago');
});
it('shows a link to the dependencies documentation page', () => {
expect(findHeaderHelpLink().attributes('href')).toBe(TEST_HOST);
});
it('displays the tabs correctly', () => { it('displays the tabs correctly', () => {
const expected = [ const expected = [
{ {
...@@ -225,6 +252,27 @@ describe('DependenciesApp component', () => { ...@@ -225,6 +252,27 @@ describe('DependenciesApp component', () => {
expect(findVulnerableTabComponent().classes('disabled')).toBe(true); expect(findVulnerableTabComponent().classes('disabled')).toBe(true);
}); });
}); });
describe('given the user has public permissions', () => {
beforeEach(() => {
store.state[allNamespace].reportInfo.generatedAt = '';
store.state[allNamespace].reportInfo.jobPath = '';
return wrapper.vm.$nextTick();
});
it('shows the header', () => {
expectHeader();
});
it('does not show when the last job ran', () => {
expect(findHeader().text()).not.toContain('1 week ago');
});
it('does not show a link to the latest job', () => {
expect(findHeaderJobLink().exists()).toBe(false);
});
});
}); });
describe('given the dependency list job failed', () => { describe('given the dependency list job failed', () => {
......
...@@ -24,6 +24,7 @@ describe('Dependencies getters', () => { ...@@ -24,6 +24,7 @@ describe('Dependencies getters', () => {
${'isJobNotSetUp'} ${'isJobNotSetUp'}
${'isJobFailed'} ${'isJobFailed'}
${'isIncomplete'} ${'isIncomplete'}
${'generatedAtTimeAgo'}
`('$getterName', ({ getterName }) => { `('$getterName', ({ getterName }) => {
it(`delegates to the current list module's ${getterName} getter`, () => { it(`delegates to the current list module's ${getterName} getter`, () => {
const mockValue = {}; const mockValue = {};
......
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import * as getters from 'ee/dependencies/store/modules/list/getters'; import * as getters from 'ee/dependencies/store/modules/list/getters';
import { REPORT_STATUS } from 'ee/dependencies/store/modules/list/constants'; import { REPORT_STATUS } from 'ee/dependencies/store/modules/list/constants';
...@@ -30,4 +31,24 @@ describe('Dependencies getters', () => { ...@@ -30,4 +31,24 @@ describe('Dependencies getters', () => {
expect(getters.downloadEndpoint({ endpoint })).toBe(`${TEST_HOST}/dependencies.json`); expect(getters.downloadEndpoint({ endpoint })).toBe(`${TEST_HOST}/dependencies.json`);
}); });
}); });
describe('generatedAtTimeAgo', () => {
it.each`
daysAgo | outcome
${1} | ${'1 day ago'}
${2} | ${'2 days ago'}
${7} | ${'1 week ago'}
`(
'should return "$outcome" when "generatedAt" was $daysAgo days ago',
({ daysAgo, outcome }) => {
const generatedAt = getDateInPast(new Date(), daysAgo);
expect(getters.generatedAtTimeAgo({ reportInfo: { generatedAt } })).toBe(outcome);
},
);
it('should return an empty string when "generatedAt" is not given', () => {
expect(getters.generatedAtTimeAgo({ reportInfo: {} })).toBe('');
});
});
}); });
...@@ -45,6 +45,7 @@ describe('Dependencies mutations', () => { ...@@ -45,6 +45,7 @@ describe('Dependencies mutations', () => {
const reportInfo = { const reportInfo = {
status: REPORT_STATUS.jobFailed, status: REPORT_STATUS.jobFailed,
job_path: 'foo', job_path: 'foo',
generated_at: 'foo',
}; };
beforeEach(() => { beforeEach(() => {
...@@ -60,6 +61,7 @@ describe('Dependencies mutations', () => { ...@@ -60,6 +61,7 @@ describe('Dependencies mutations', () => {
expect(state.reportInfo).toEqual({ expect(state.reportInfo).toEqual({
status: REPORT_STATUS.jobFailed, status: REPORT_STATUS.jobFailed,
jobPath: 'foo', jobPath: 'foo',
generatedAt: 'foo',
}); });
}); });
}); });
...@@ -78,6 +80,7 @@ describe('Dependencies mutations', () => { ...@@ -78,6 +80,7 @@ describe('Dependencies mutations', () => {
expect(state.reportInfo).toEqual({ expect(state.reportInfo).toEqual({
status: REPORT_STATUS.ok, status: REPORT_STATUS.ok,
jobPath: '', jobPath: '',
generatedAt: '',
}); });
}); });
}); });
......
...@@ -5293,6 +5293,9 @@ msgstr "" ...@@ -5293,6 +5293,9 @@ msgstr ""
msgid "Dependencies" msgid "Dependencies"
msgstr "" msgstr ""
msgid "Dependencies help page link"
msgstr ""
msgid "Dependencies|%d additional vulnerability not shown" msgid "Dependencies|%d additional vulnerability not shown"
msgid_plural "Dependencies|%d additional vulnerabilities not shown" msgid_plural "Dependencies|%d additional vulnerabilities not shown"
msgstr[0] "" msgstr[0] ""
...@@ -5789,6 +5792,9 @@ msgstr "" ...@@ -5789,6 +5792,9 @@ msgstr ""
msgid "Display name" msgid "Display name"
msgstr "" msgstr ""
msgid "Displays dependencies and known vulnerabilities, based on the %{linkStart}latest pipeline%{linkEnd} scan"
msgstr ""
msgid "Do not display offers from third parties within GitLab" msgid "Do not display offers from third parties within GitLab"
msgstr "" msgstr ""
......
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