Commit 0e6fbe01 authored by Mark Florian's avatar Mark Florian Committed by Nathan Friend

Update dependencies table UI

Part of [Update dependencies table UI][1].

This is the largest piece that implements the new table layout. It's
behind the `dependency_list_ui` feature flag, which is currently
disabled by default. This means that the existing UI is still in place,
and unaffected.

Specifically, this:

- Reimplements the table using `GlTable` from GitLab UI
- Removes the UI tabs
- Combines the component name and version columns
- Adds a warning badge for rows with vulnerabilities
- Adds a document icon to the location column

[1]: https://gitlab.com/gitlab-org/gitlab/-/issues/195928
parent a132a3ac
......@@ -3,6 +3,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import {
GlBadge,
GlEmptyState,
GlIcon,
GlLoadingIcon,
GlSprintf,
GlTab,
......@@ -11,7 +12,7 @@ import {
GlDeprecatedButton,
} from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DependenciesActions from './dependencies_actions.vue';
import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue';
......@@ -24,6 +25,7 @@ export default {
components: {
DependenciesActions,
GlBadge,
GlIcon,
GlEmptyState,
GlLoadingIcon,
GlSprintf,
......@@ -33,9 +35,9 @@ export default {
GlDeprecatedButton,
DependencyListIncompleteAlert,
DependencyListJobFailedAlert,
Icon,
PaginatedDependenciesTable,
},
mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
......@@ -157,36 +159,47 @@ export default {
@dismiss="dismissJobFailedAlert"
/>
<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">
<gl-sprintf
:message="s__('Dependencies|Based on the %{linkStart}latest successful%{linkEnd} scan')"
>
<template #link="{ content }">
<gl-link v-if="reportInfo.jobPath" ref="jobLink" :href="reportInfo.jobPath">{{
content
}}</gl-link>
<template v-else>{{ content }}</template>
</template>
</gl-sprintf>
<span v-if="generatedAtTimeAgo">
<span aria-hidden="true">&bull;</span>
<span class="text-secondary">{{ generatedAtTimeAgo }}</span>
</span>
</p>
<header class="d-md-flex align-items-end my-3">
<div class="mr-auto">
<h2 class="h4 mb-1 mt-0">
{{ __('Dependencies') }}
<gl-link
target="_blank"
:href="documentationPath"
:aria-label="__('Dependencies help page link')"
>
<gl-icon name="question" />
</gl-link>
</h2>
<p class="mb-0">
<gl-sprintf
:message="s__('Dependencies|Based on the %{linkStart}latest successful%{linkEnd} scan')"
>
<template #link="{ content }">
<gl-link v-if="reportInfo.jobPath" ref="jobLink" :href="reportInfo.jobPath">{{
content
}}</gl-link>
<template v-else>{{ content }}</template>
</template>
</gl-sprintf>
<span v-if="generatedAtTimeAgo">
<span aria-hidden="true">&bull;</span>
<span class="text-secondary">{{ generatedAtTimeAgo }}</span>
</span>
</p>
</div>
<dependencies-actions
v-if="glFeatures.dependencyListUi"
class="mt-2"
:namespace="currentList"
/>
</header>
<gl-tabs v-model="currentListIndex" content-class="pt-0">
<article v-if="glFeatures.dependencyListUi">
<paginated-dependencies-table :namespace="currentList" />
</article>
<gl-tabs v-else v-model="currentListIndex" content-class="pt-0">
<gl-tab
v-for="listType in listTypes"
:key="listType.namespace"
......
<script>
import { cloneDeep } from 'lodash';
import { GlBadge, GlIcon, GlLink, GlNewButton, GlSkeletonLoading, GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DependenciesTableRow from './dependencies_table_row.vue';
import DependencyLicenseLinks from './dependency_license_links.vue';
import DependencyVulnerabilities from './dependency_vulnerabilities.vue';
const tdClass = (value, key, item) => {
const classes = [];
// Don't draw a border between a row and its `row-details` slot
// eslint-disable-next-line no-underscore-dangle
if (item._showDetails) {
classes.push('border-bottom-0');
}
if (key === 'isVulnerable') {
classes.push('text-right');
}
return classes;
};
export default {
name: 'DependenciesTable',
components: {
DependenciesTableRow,
DependencyLicenseLinks,
DependencyVulnerabilities,
GlBadge,
GlIcon,
GlLink,
GlNewButton,
GlSkeletonLoading,
GlTable,
},
mixins: [glFeatureFlagsMixin()],
props: {
dependencies: {
type: Array,
......@@ -27,13 +57,112 @@ export default {
{ className: 'section-15', label: s__('Dependencies|License') },
];
return { tableSections };
return {
localDependencies: this.transformDependenciesForUI(this.dependencies),
tableSections,
};
},
computed: {
anyDependencyHasVulnerabilities() {
return this.localDependencies.some(({ vulnerabilities }) => vulnerabilities.length > 0);
},
},
watch: {
dependencies(dependencies) {
this.localDependencies = this.transformDependenciesForUI(dependencies);
},
},
methods: {
// The GlTable component mutates the `_showDetails` property on items
// passed to it in order to track the visibilty of each row's `row-details`
// slot. So, create a deep clone of them here to avoid mutating the
// `dependencies` prop.
transformDependenciesForUI(dependencies) {
return cloneDeep(dependencies);
},
},
fields: [
{ key: 'component', label: s__('Dependencies|Component'), tdClass },
{ key: 'packager', label: s__('Dependencies|Packager'), tdClass },
{ key: 'location', label: s__('Dependencies|Location'), tdClass },
{ key: 'license', label: s__('Dependencies|License'), tdClass },
{ key: 'isVulnerable', label: '', tdClass },
],
DEPENDENCIES_PER_PAGE: 20,
};
</script>
<template>
<div>
<gl-table
v-if="glFeatures.dependencyListUi"
:fields="$options.fields"
:items="localDependencies"
:busy="isLoading"
details-td-class="pt-0"
stacked="md"
>
<!-- toggleDetails and detailsShowing are scoped slot props provided by
GlTable; they mutate/read the item's _showDetails property, which GlTable
uses to show/hide the row-details slot -->
<template #cell(component)="{ item, toggleDetails, detailsShowing }">
<gl-new-button
v-if="anyDependencyHasVulnerabilities"
class="d-none d-md-inline"
:class="{ invisible: !item.vulnerabilities.length }"
variant="link"
:aria-label="s__('Dependencies|Toggle vulnerability list')"
@click="toggleDetails"
>
<gl-icon
:name="detailsShowing ? 'chevron-up' : 'chevron-down'"
class="text-secondary-900"
/>
</gl-new-button>
<span class="bold">{{ item.name }}</span
>&nbsp;{{ item.version }}
</template>
<template #cell(location)="{ item }">
<gl-link :href="item.location.blob_path">
<gl-icon name="doc-text" class="align-middle" />
{{ item.location.path }}
</gl-link>
</template>
<template #cell(license)="{ item }">
<dependency-license-links :licenses="item.licenses" :title="item.name" />
</template>
<template #cell(isVulnerable)="{ item, toggleDetails }">
<gl-badge
v-if="item.vulnerabilities.length"
variant="warning"
href="#"
@click.native="toggleDetails"
>
<gl-icon name="warning" class="text-warning-500 mr-1" />
{{
n__(
'Dependencies|%d vulnerability detected',
'Dependencies|%d vulnerabilities detected',
item.vulnerabilities.length,
)
}}
</gl-badge>
</template>
<template #row-details="{ item }">
<dependency-vulnerabilities class="ml-4" :vulnerabilities="item.vulnerabilities" />
</template>
<template #table-busy>
<div class="mt-2">
<gl-skeleton-loading v-for="n in $options.DEPENDENCIES_PER_PAGE" :key="n" :lines="1" />
</div>
</template>
</gl-table>
<div v-else>
<div class="gl-responsive-table-row table-row-header text-2 bg-secondary-50 px-2" role="row">
<div
v-for="(section, index) in tableSections"
......
......@@ -4,6 +4,10 @@ module Projects
class DependenciesController < Projects::ApplicationController
before_action :authorize_read_dependency_list!
before_action only: [:index] do
push_frontend_feature_flag(:dependency_list_ui, project)
end
def index
respond_to do |format|
format.html do
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DependenciesApp component given the dependencyListUi feature flag is enabled on creation given the dependency list job has not yet run shows only the empty state 1`] = `
Object {
"compact": false,
"description": "The dependency list details information about the components used within your project.",
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
"secondaryButtonText": null,
"svgPath": "/bar.svg",
"title": "View dependency details for your project",
}
`;
exports[`DependenciesApp component given the dependencyListUi feature flag is enabled on creation given there are no dependencies detected shows only the empty state 1`] = `
Object {
"compact": false,
"description": "It seems like the Dependency Scanning job ran successfully, but no dependencies have been detected in your project.",
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
"secondaryButtonText": null,
"svgPath": "/bar.svg",
"title": "Dependency List has no entries",
}
`;
exports[`DependenciesApp component on creation given the dependency list job has not yet run shows only the empty state 1`] = `
Object {
"compact": false,
......
......@@ -25,7 +25,7 @@ describe('DependenciesApp component', () => {
supportDocumentationPath: `${TEST_HOST}/dependency_scanning#supported-languages`,
};
const factory = (props = basicAppProps) => {
const factory = ({ props = basicAppProps, ...options } = {}) => {
store = createStore();
addListType(store, DEPENDENCY_LIST_TYPES.vulnerable);
jest.spyOn(store, 'dispatch').mockImplementation();
......@@ -37,6 +37,7 @@ describe('DependenciesApp component', () => {
store,
propsData: { ...props },
stubs,
...options,
});
};
......@@ -123,6 +124,12 @@ describe('DependenciesApp component', () => {
const expectNoDependenciesTables = () => expect(findDependenciesTables()).toHaveLength(0);
const expectNoHeader = () => expect(findHeader().exists()).toBe(false);
const expectDependenciesTable = () => {
const tables = findDependenciesTables();
expect(tables).toHaveLength(1);
expect(tables.at(0).props()).toEqual({ namespace: allNamespace });
};
const expectDependenciesTables = () => {
const tables = findDependenciesTables();
expect(tables).toHaveLength(2);
......@@ -355,4 +362,157 @@ describe('DependenciesApp component', () => {
});
});
});
describe('given the dependencyListUi feature flag is enabled', () => {
describe('on creation', () => {
beforeEach(() => {
factory({ provide: { glFeatures: { dependencyListUi: true } } });
});
it('dispatches the correct initial actions', () => {
expect(store.dispatch.mock.calls).toEqual([
['setDependenciesEndpoint', basicAppProps.endpoint],
['fetchDependencies'],
]);
});
it('shows only the loading icon', () => {
expectComponentWithProps(GlLoadingIcon);
expectNoHeader();
expectNoDependenciesTables();
});
describe('given the dependency list job has not yet run', () => {
beforeEach(() => {
setStateJobNotRun();
return wrapper.vm.$nextTick();
});
it('shows only the empty state', () => {
expectComponentWithProps(GlEmptyState, { svgPath: basicAppProps.emptyStateSvgPath });
expectComponentPropsToMatchSnapshot(GlEmptyState);
expectNoHeader();
expectNoDependenciesTables();
});
});
describe('given a list of dependencies and ok report', () => {
beforeEach(() => {
setStateLoaded();
return wrapper.vm.$nextTick();
});
it('shows the dependencies table with the correct props', () => {
expectHeader();
expectDependenciesTable();
});
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('passes the correct namespace to dependencies actions component', () => {
expectComponentWithProps(DependenciesActions, { namespace: allNamespace });
});
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', () => {
beforeEach(() => {
setStateJobFailed();
return wrapper.vm.$nextTick();
});
it('passes the correct props to the job failure alert', () => {
expectComponentWithProps(DependencyListJobFailedAlert, {
jobPath: '/jobs/foo/321',
});
});
it('shows the dependencies table with the correct props', expectDependenciesTable);
describe('when the job failure alert emits the dismiss event', () => {
beforeEach(() => {
const alertWrapper = findJobFailedAlert();
alertWrapper.vm.$emit('dismiss');
return wrapper.vm.$nextTick();
});
it('does not render the job failure alert', () => {
expect(findJobFailedAlert().exists()).toBe(false);
});
});
});
describe('given a dependency list which is known to be incomplete', () => {
beforeEach(() => {
setStateListIncomplete();
return wrapper.vm.$nextTick();
});
it('passes the correct props to the incomplete-list alert', () => {
expectComponentWithProps(DependencyListIncompleteAlert);
});
it('shows the dependencies table with the correct props', expectDependenciesTable);
describe('when the incomplete-list alert emits the dismiss event', () => {
beforeEach(() => {
const alertWrapper = findIncompleteListAlert();
alertWrapper.vm.$emit('dismiss');
return wrapper.vm.$nextTick();
});
it('does not render the incomplete-list alert', () => {
expect(findIncompleteListAlert().exists()).toBe(false);
});
});
});
describe('given there are no dependencies detected', () => {
beforeEach(() => {
setStateNoDependencies();
});
it('shows only the empty state', () => {
expectComponentWithProps(GlEmptyState, { svgPath: basicAppProps.emptyStateSvgPath });
expectComponentPropsToMatchSnapshot(GlEmptyState);
expectNoHeader();
expectNoDependenciesTables();
});
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import { GlBadge, GlNewButton, GlLink, GlSkeletonLoading } from '@gitlab/ui';
import DependenciesTable from 'ee/dependencies/components/dependencies_table.vue';
import DependenciesTableRow from 'ee/dependencies/components/dependencies_table_row.vue';
import DependencyLicenseLinks from 'ee/dependencies/components/dependency_license_links.vue';
import DependencyVulnerabilities from 'ee/dependencies/components/dependency_vulnerabilities.vue';
import { makeDependency } from './utils';
describe('DependenciesTable component', () => {
......@@ -62,4 +66,213 @@ describe('DependenciesTable component', () => {
});
});
});
describe('given the dependencyListUi feature flag is enabled', () => {
const createComponent = ({ propsData, ...options } = {}) => {
const stubs = Object.keys(DependenciesTable.components).filter(
component => component !== 'GlTable',
);
wrapper = mount(DependenciesTable, {
...options,
propsData: { ...propsData },
provide: { glFeatures: { dependencyListUi: true } },
stubs,
});
};
const findTableRows = () => wrapper.findAll('tbody > tr');
const findRowToggleButtons = () => wrapper.findAll(GlNewButton);
const findDependencyVulnerabilities = () => wrapper.find(DependencyVulnerabilities);
const normalizeWhitespace = string => string.replace(/\s+/g, ' ');
const expectDependencyRow = (rowWrapper, dependency) => {
const [
componentCell,
packagerCell,
locationCell,
licenseCell,
isVulnerableCell,
] = rowWrapper.findAll('td').wrappers;
expect(normalizeWhitespace(componentCell.text())).toBe(
`${dependency.name} ${dependency.version}`,
);
expect(packagerCell.text()).toBe(dependency.packager);
const locationLink = locationCell.find(GlLink);
expect(locationLink.attributes().href).toBe(dependency.location.blob_path);
expect(locationLink.text()).toBe(dependency.location.path);
const licenseLinks = licenseCell.find(DependencyLicenseLinks);
expect(licenseLinks.exists()).toBe(true);
expect(licenseLinks.props()).toEqual({
licenses: dependency.licenses,
title: dependency.name,
});
const isVulnerableCellText = normalizeWhitespace(isVulnerableCell.text());
if (dependency.vulnerabilities.length) {
expect(isVulnerableCellText).toContain(`${dependency.vulnerabilities.length} vuln`);
} else {
expect(isVulnerableCellText).toBe('');
}
};
describe('given the table is loading', () => {
let dependencies;
beforeEach(() => {
dependencies = [makeDependency()];
createComponent({
propsData: {
dependencies,
isLoading: true,
},
});
});
it('renders the loading skeleton', () => {
expect(wrapper.contains(GlSkeletonLoading)).toBe(true);
});
it('does not render any dependencies', () => {
expect(wrapper.text()).not.toContain(dependencies[0].name);
});
});
describe('given an empty list of dependencies', () => {
beforeEach(() => {
createComponent({
propsData: {
dependencies: [],
isLoading: false,
},
});
});
it('renders the table header', () => {
const expectedLabels = DependenciesTable.fields.map(({ label }) => label);
const headerCells = wrapper.findAll('thead th').wrappers;
expect(headerCells.map(cell => cell.text())).toEqual(expectedLabels);
});
it('does not render any rows', () => {
expect(findTableRows()).toHaveLength(0);
});
});
describe('given dependencies with no vulnerabilities', () => {
let dependencies;
beforeEach(() => {
dependencies = [
makeDependency({ vulnerabilities: [] }),
makeDependency({ name: 'foo', vulnerabilities: [] }),
];
createComponent({
propsData: {
dependencies,
isLoading: false,
},
});
});
it('renders a row for each dependency', () => {
const rows = findTableRows();
dependencies.forEach((dependency, i) => {
expectDependencyRow(rows.at(i), dependency);
});
});
it('does not render any row toggle buttons', () => {
expect(findRowToggleButtons()).toHaveLength(0);
});
it('does not render vulnerability details', () => {
expect(findDependencyVulnerabilities().exists()).toBe(false);
});
});
describe('given some dependencies with vulnerabilities', () => {
let dependencies;
beforeEach(() => {
dependencies = [
makeDependency({ name: 'qux', vulnerabilities: ['bar', 'baz'] }),
makeDependency({ vulnerabilities: [] }),
// Guarantee that the component doesn't mutate these, but still
// maintains its row-toggling behaviour (i.e., via _showDetails)
].map(Object.freeze);
createComponent({
propsData: {
dependencies,
isLoading: false,
},
});
});
it('renders a row for each dependency', () => {
const rows = findTableRows();
dependencies.forEach((dependency, i) => {
expectDependencyRow(rows.at(i), dependency);
});
});
it('render the toggle button for each row', () => {
const toggleButtons = findRowToggleButtons();
dependencies.forEach((dependency, i) => {
const button = toggleButtons.at(i);
expect(button.exists()).toBe(true);
expect(button.classes('invisible')).toBe(dependency.vulnerabilities.length === 0);
});
});
it('does not render vulnerability details', () => {
expect(findDependencyVulnerabilities().exists()).toBe(false);
});
describe('the dependency vulnerabilities', () => {
let rowIndexWithVulnerabilities;
beforeEach(() => {
rowIndexWithVulnerabilities = dependencies.findIndex(
dep => dep.vulnerabilities.length > 0,
);
});
it('can be displayed by clicking on the toggle button', () => {
const toggleButton = findRowToggleButtons().at(rowIndexWithVulnerabilities);
toggleButton.vm.$emit('click');
return nextTick().then(() => {
expect(findDependencyVulnerabilities().props()).toEqual({
vulnerabilities: dependencies[rowIndexWithVulnerabilities].vulnerabilities,
});
});
});
it('can be displayed by clicking on the vulnerabilities badge', () => {
const badge = findTableRows()
.at(rowIndexWithVulnerabilities)
.find(GlBadge);
badge.trigger('click');
return nextTick().then(() => {
expect(findDependencyVulnerabilities().props()).toEqual({
vulnerabilities: dependencies[rowIndexWithVulnerabilities].vulnerabilities,
});
});
});
});
});
});
});
......@@ -6645,6 +6645,11 @@ msgid_plural "Dependencies|%d vulnerabilities"
msgstr[0] ""
msgstr[1] ""
msgid "Dependencies|%d vulnerability detected"
msgid_plural "Dependencies|%d vulnerabilities detected"
msgstr[0] ""
msgstr[1] ""
msgid "Dependencies|%{remainingLicensesCount} more"
msgstr ""
......@@ -6684,6 +6689,9 @@ msgstr ""
msgid "Dependencies|The %{codeStartTag}dependency_scanning%{codeEndTag} job has failed and cannot generate the list. Please ensure the job is running properly and run the pipeline again."
msgstr ""
msgid "Dependencies|Toggle vulnerability list"
msgstr ""
msgid "Dependencies|Unsupported file(s) detected"
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