Commit c0914223 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'license-list-page' into 'master'

Add license list to "Security and Compliance" nav section - Add license list, modals, and links

See merge request gitlab-org/gitlab!18934
parents f8292c71 13901b15
......@@ -29,6 +29,8 @@
.border-color-default { border-color: $border-color; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
.mh-50vh { max-height: 50vh; }
.gl-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
.gl-bg-blue-500 { @include gl-bg-blue-500; }
import Vue from 'vue';
import { __ } from '~/locale';
import initProjectLicensesApp from 'ee/project_licenses';
if (gon.features && gon.features.licensesList) {
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
el: '#js-licenses-app',
render(createElement) {
return createElement('h1', __('License Compliance'));
},
}),
);
}
document.addEventListener('DOMContentLoaded', initProjectLicensesApp);
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlEmptyState, GlLoadingIcon, GlLink, GlIcon } from '@gitlab/ui';
import { LICENSE_LIST } from '../store/constants';
import PaginatedLicensesTable from './paginated_licenses_table.vue';
import PipelineInfo from './pipeline_info.vue';
export default {
name: 'ProjectLicensesApp',
components: {
GlEmptyState,
GlLoadingIcon,
GlLink,
PaginatedLicensesTable,
PipelineInfo,
GlIcon,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
documentationPath: {
type: String,
required: true,
},
},
computed: {
...mapState(LICENSE_LIST, ['initialized', 'reportInfo']),
...mapGetters(LICENSE_LIST, ['isJobSetUp', 'isJobFailed']),
hasEmptyState() {
return Boolean(!this.isJobSetUp || this.isJobFailed);
},
},
created() {
this.fetchLicenses();
},
methods: {
...mapActions(LICENSE_LIST, ['fetchLicenses']),
},
};
</script>
<template>
<gl-loading-icon v-if="!initialized" size="md" class="mt-4" />
<gl-empty-state
v-else-if="hasEmptyState"
:title="s__('Licenses|View license details for your project')"
:description="
s__(
'Licenses|The license list details information about the licenses used within your project.',
)
"
:svg-path="emptyStateSvgPath"
:primary-button-link="documentationPath"
:primary-button-text="s__('Licenses|Learn more about license compliance')"
/>
<div v-else>
<h2 class="h4">
{{ s__('Licenses|License Compliance') }}
<gl-link :href="documentationPath" class="vertical-align-middle" target="_blank">
<gl-icon name="question" />
</gl-link>
</h2>
<pipeline-info :path="reportInfo.jobPath" :timestamp="reportInfo.generatedAt" />
<paginated-licenses-table class="mt-3" />
</div>
</template>
<script>
import { uniqueId } from 'underscore';
import { sprintf, s__ } from '~/locale';
import { GlLink, GlIntersperse, GlModal, GlButton, GlModalDirective } from '@gitlab/ui';
const MODAL_ID_PREFIX = 'license-component-link-modal-';
export const VISIBLE_COMPONENT_COUNT = 2;
export default {
components: {
GlIntersperse,
GlLink,
GlButton,
GlModal,
},
directives: {
GlModalDirective,
},
props: {
title: {
type: String,
required: true,
},
components: {
type: Array,
required: true,
},
},
computed: {
modalId() {
return uniqueId(MODAL_ID_PREFIX);
},
visibleComponents() {
return this.components.slice(0, VISIBLE_COMPONENT_COUNT);
},
remainingComponentsCount() {
return Math.max(0, this.components.length - VISIBLE_COMPONENT_COUNT);
},
hasComponentsInModal() {
return this.remainingComponentsCount > 0;
},
lastSeparator() {
return ` ${s__('SeriesFinalConjunction|and')} `;
},
modalButtonText() {
const { remainingComponentsCount } = this;
return sprintf(s__('Licenses|%{remainingComponentsCount} more'), {
remainingComponentsCount,
});
},
modalActionText() {
return s__('Modal|Close');
},
},
};
</script>
<template>
<div>
<gl-intersperse :last-separator="lastSeparator" class="js-component-links-component-list">
<span
v-for="(component, index) in visibleComponents"
:key="index"
class="js-component-links-component-list-item"
>
<gl-link v-if="component.blob_path" :href="component.blob_path" target="_blank">{{
component.name
}}</gl-link>
<template v-else>{{ component.name }}</template>
</span>
<gl-button
v-if="hasComponentsInModal"
v-gl-modal-directive="modalId"
variant="link"
class="align-baseline js-component-links-modal-trigger"
>
{{ modalButtonText }}
</gl-button>
</gl-intersperse>
<gl-modal
v-if="hasComponentsInModal"
:title="title"
:modal-id="modalId"
:ok-title="modalActionText"
ok-only
ok-variant="secondary"
>
<h5>{{ s__('Licenses|Components') }}</h5>
<ul class="list-unstyled overflow-auto mh-50vh">
<li
v-for="component in components"
:key="component.name"
class="js-component-links-modal-item"
>
<gl-link v-if="component.blob_path" :href="component.blob_path" target="_blank">{{
component.name
}}</gl-link>
<span v-else>{{ component.name }}</span>
</li>
</ul>
</gl-modal>
</div>
</template>
<script>
import { s__ } from '~/locale';
import LicensesTableRow from './licenses_table_row.vue';
export default {
name: 'LicensesTable',
components: {
LicensesTableRow,
},
props: {
licenses: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
data() {
return {
tableHeaders: [
{ className: 'section-30', label: s__('Licenses|Name') },
{ className: 'section-70', label: s__('Licenses|Component') },
],
};
},
};
</script>
<template>
<div>
<div class="gl-responsive-table-row table-row-header text-2 bg-secondary-50 px-2" role="row">
<div
v-for="(header, index) in tableHeaders"
:key="index"
class="table-section"
:class="header.className"
role="rowheader"
>
{{ header.label }}
</div>
</div>
<licenses-table-row
v-for="(license, index) in licenses"
:key="index"
:license="license"
:is-loading="isLoading"
/>
</div>
</template>
<script>
import { GlLink, GlSkeletonLoading } from '@gitlab/ui';
import LicenseComponentLinks from './license_component_links.vue';
export default {
name: 'LicensesTableRow',
components: {
LicenseComponentLinks,
GlLink,
GlSkeletonLoading,
},
props: {
license: {
type: Object,
required: false,
default: null,
},
isLoading: {
type: Boolean,
required: false,
default: true,
},
},
};
</script>
<template>
<div class="gl-responsive-table-row flex-md-column align-items-md-stretch px-2">
<gl-skeleton-loading
v-if="isLoading"
:lines="1"
class="d-flex flex-column justify-content-center h-auto"
/>
<div v-else class="d-md-flex align-items-baseline js-license-row">
<!-- Name-->
<div class="table-section section-30 section-wrap pr-md-3">
<div class="table-mobile-header" role="rowheader">
{{ s__('Licenses|Name') }}
</div>
<div class="table-mobile-content">
<gl-link v-if="license.url" :href="license.url" target="_blank">{{
license.name
}}</gl-link>
<template v-else>{{ license.name }}</template>
</div>
</div>
<!-- Component -->
<div class="table-section section-70 section-wrap pr-md-3">
<div class="table-mobile-header" role="rowheader">{{ s__('Licenses|Component') }}</div>
<div class="table-mobile-content">
<license-component-links :components="license.components" :title="license.name" />
</div>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import Pagination from '~/vue_shared/components/pagination_links.vue';
import LicensesTable from './licenses_table.vue';
import { LICENSE_LIST } from '../store/constants';
export default {
name: 'PaginatedLicensesTable',
components: {
LicensesTable,
Pagination,
},
computed: {
...mapState(LICENSE_LIST, ['licenses', 'isLoading', 'initialized', 'pageInfo']),
shouldShowPagination() {
const { initialized, pageInfo } = this;
return Boolean(initialized && pageInfo && pageInfo.total);
},
},
methods: {
...mapActions(LICENSE_LIST, ['fetchLicenses']),
fetchPage(page) {
return this.fetchLicenses({ page });
},
},
};
</script>
<template>
<div>
<licenses-table :licenses="licenses" :is-loading="isLoading" />
<pagination
v-if="shouldShowPagination"
:change="fetchPage"
:page-info="pageInfo"
class="justify-content-center mt-3"
/>
</div>
</template>
<script>
import { escape } from 'underscore';
import { s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'PipelineInfo',
components: {
TimeAgoTooltip,
},
props: {
path: {
required: true,
type: String,
},
timestamp: {
required: true,
type: String,
},
},
computed: {
pipelineText() {
const { path } = this;
const body = s__(
'Licenses|Displays licenses detected in the project, based on the %{linkStart}latest pipeline%{linkEnd} scan',
);
const linkStart = path
? `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`
: '';
const linkEnd = path ? '</a>' : '';
return sprintf(body, { linkStart, linkEnd }, false);
},
hasFullPipelineText() {
return Boolean(this.path && this.timestamp);
},
},
};
</script>
<template>
<span v-if="hasFullPipelineText">
<span v-html="pipelineText"></span>
<span></span>
<time-ago-tooltip :time="timestamp" />
</span>
<span v-else v-html="pipelineText"></span>
</template>
import Vue from 'vue';
import ProjectLicensesApp from './components/app.vue';
import createStore from './store';
import { LICENSE_LIST } from './store/constants';
export default () => {
const el = document.querySelector('#js-licenses-app');
const { endpoint, emptyStateSvgPath, documentationPath } = el.dataset;
const store = createStore();
store.dispatch(`${LICENSE_LIST}/setLicensesEndpoint`, endpoint);
return new Vue({
el,
store,
components: {
ProjectLicensesApp,
},
render(createElement) {
return createElement(ProjectLicensesApp, {
props: {
emptyStateSvgPath,
documentationPath,
},
});
},
});
};
/* eslint-disable import/prefer-default-export */
export const LICENSE_LIST = 'licenseList';
import Vue from 'vue';
import Vuex from 'vuex';
import listModule from './modules/list';
import { LICENSE_LIST } from './constants';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
modules: {
[LICENSE_LIST]: listModule(),
},
});
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { FETCH_ERROR_MESSAGE } from './constants';
import * as types from './mutation_types';
export const setLicensesEndpoint = ({ commit }, endpoint) =>
commit(types.SET_LICENSES_ENDPOINT, endpoint);
export const fetchLicenses = ({ state, dispatch }, params = {}) => {
if (!state.endpoint) {
return Promise.reject(new Error(__('No endpoint provided')));
}
dispatch('requestLicenses');
return axios
.get(state.endpoint, {
params: {
per_page: 10,
page: state.pageInfo.page || 1,
...params,
},
})
.then(response => {
dispatch('receiveLicensesSuccess', response);
})
.catch(error => {
dispatch('receiveLicensesError', error);
});
};
export const requestLicenses = ({ commit }) => commit(types.REQUEST_LICENSES);
export const receiveLicensesSuccess = ({ commit }, { headers, data }) => {
const normalizedHeaders = normalizeHeaders(headers);
const pageInfo = parseIntPagination(normalizedHeaders);
const { licenses, report: reportInfo } = data;
commit(types.RECEIVE_LICENSES_SUCCESS, { licenses, reportInfo, pageInfo });
};
export const receiveLicensesError = ({ commit }) => {
commit(types.RECEIVE_LICENSES_ERROR);
createFlash(FETCH_ERROR_MESSAGE);
};
import { s__ } from '~/locale';
export const REPORT_STATUS = {
ok: 'ok',
jobNotSetUp: 'job_not_set_up',
jobFailed: 'job_failed',
noLicenses: 'no_licenses',
incomplete: 'no_license_files',
};
export const FETCH_ERROR_MESSAGE = s__(
'Licenses|Error fetching the license list. Please check your network connection and try again.',
);
import { REPORT_STATUS } from './constants';
export const isJobSetUp = state => state.reportInfo.status !== REPORT_STATUS.jobNotSetUp;
export const isJobFailed = state =>
[REPORT_STATUS.jobFailed, REPORT_STATUS.noLicenses, REPORT_STATUS.incomplete].includes(
state.reportInfo.status,
);
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
actions,
getters,
mutations,
state,
});
export const SET_LICENSES_ENDPOINT = 'SET_LICENSES_ENDPOINT';
export const REQUEST_LICENSES = 'REQUEST_LICENSES';
export const RECEIVE_LICENSES_SUCCESS = 'RECEIVE_LICENSES_SUCCESS';
export const RECEIVE_LICENSES_ERROR = 'RECEIVE_LICENSES_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_LICENSES_ENDPOINT](state, payload) {
state.endpoint = payload;
},
[types.REQUEST_LICENSES](state) {
state.isLoading = true;
state.errorLoading = false;
},
[types.RECEIVE_LICENSES_SUCCESS](state, { licenses, reportInfo, pageInfo }) {
state.licenses = licenses;
state.pageInfo = pageInfo;
state.isLoading = false;
state.errorLoading = false;
state.initialized = true;
state.reportInfo = {
status: reportInfo.status,
jobPath: reportInfo.job_path,
generatedAt: reportInfo.generated_at,
};
},
[types.RECEIVE_LICENSES_ERROR](state) {
state.isLoading = false;
state.errorLoading = true;
state.initialized = true;
},
};
import { REPORT_STATUS } from './constants';
export default () => ({
endpoint: '',
initialized: false,
isLoading: false,
errorLoading: false,
licenses: [],
pageInfo: {
total: 0,
},
reportInfo: {
status: REPORT_STATUS.ok,
jobPath: '',
generatedAt: '',
},
});
......@@ -3,9 +3,5 @@
module Projects
class LicensesController < Projects::ApplicationController
before_action :authorize_read_licenses_list!
before_action do
push_frontend_feature_flag(:licenses_list)
end
end
end
......@@ -6,10 +6,6 @@ module Projects
before_action :authorize_read_licenses_list!
before_action :authorize_admin_software_license_policy!, only: [:create, :update]
before_action do
push_frontend_feature_flag(:licenses_list)
end
def index
respond_to do |format|
format.json do
......
......@@ -45,7 +45,7 @@ module EE
nav_tabs << :dependencies
end
if ::Feature.enabled?(:licenses_list) && can?(current_user, :read_licenses_list, project)
if can?(current_user, :read_licenses_list, project)
nav_tabs << :licenses
end
......
- breadcrumb_title _('License Compliance')
- page_title _('License Compliance')
#js-licenses-app
#js-licenses-app{ data: { endpoint: project_security_licenses_path(@project),
documentation_path: help_page_path('user/application_security/license_compliance/index'),
empty_state_svg_path: image_path('illustrations/Dependency-list-empty-state.svg') } }
......@@ -65,7 +65,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :dashboards, only: [:create]
end
resources :licenses, only: [:index, :create, :update], controller: 'security/licenses'
resource :licenses, only: [:show]
namespace :security do
resources :licenses, only: [:index, :create, :update]
end
resources :environments, only: [] do
member do
......@@ -193,7 +196,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resource :configuration, only: [:show], controller: :configuration
resources :dependencies, only: [:index]
resources :licenses, only: [:index, :update]
# We have to define both legacy and new routes for Vulnerability Findings
# because they are loaded upon application initialization and preloaded by
# web server.
......@@ -214,8 +216,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :vulnerability_feedback, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ }
resource :dependencies, only: [:show]
resource :licenses, only: [:show]
# All new routes should go under /-/ scope.
# Look for scope '-' at the top of the file.
# rubocop: enable Cop/PutProjectRoutesUnderScope
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LicensesTable component given a list of licenses (loaded) renders the table headers 1`] = `
<div
class="gl-responsive-table-row table-row-header text-2 bg-secondary-50 px-2"
role="row"
>
<div
class="table-section section-30"
role="rowheader"
>
Name
</div>
<div
class="table-section section-70"
role="rowheader"
>
Component
</div>
</div>
`;
exports[`LicensesTable component given a list of licenses (loading) renders the table headers 1`] = `
<div
class="gl-responsive-table-row table-row-header text-2 bg-secondary-50 px-2"
role="row"
>
<div
class="table-section section-30"
role="rowheader"
>
Name
</div>
<div
class="table-section section-70"
role="rowheader"
>
Component
</div>
</div>
`;
exports[`LicensesTable component given an empty list of licenses renders the table headers 1`] = `
<div
class="gl-responsive-table-row table-row-header text-2 bg-secondary-50 px-2"
role="row"
>
<div
class="table-section section-30"
role="rowheader"
>
Name
</div>
<div
class="table-section section-70"
role="rowheader"
>
Component
</div>
</div>
`;
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlModal, GlLink, GlIntersperse } from '@gitlab/ui';
import LicenseComponentLinks, {
VISIBLE_COMPONENT_COUNT,
} from 'ee/project_licenses/components/license_component_links.vue';
describe('LicenseComponentLinks component', () => {
// local Vue
const localVue = createLocalVue();
// data helpers
const createComponents = n => [...Array(n).keys()].map(i => ({ name: `component ${i + 1}` }));
const addUrls = (components, numComponentsWithUrls = Infinity) =>
components.map((comp, i) => ({
...comp,
...(i < numComponentsWithUrls ? { blob_path: `component ${i + 1}` } : {}),
}));
// wrapper / factory
let wrapper;
const factory = ({ numComponents, numComponentsWithUrl = 0, title = 'test-component' } = {}) => {
const components = addUrls(createComponents(numComponents), numComponentsWithUrl);
wrapper = shallowMount(localVue.extend(LicenseComponentLinks), {
localVue,
propsData: {
components,
title,
},
sync: false,
});
};
// query helpers
const findComponentsList = () => wrapper.find('.js-component-links-component-list');
const findComponentListItems = () => wrapper.findAll('.js-component-links-component-list-item');
const findModal = () => wrapper.find(GlModal);
const findModalItem = () => wrapper.findAll('.js-component-links-modal-item');
const findModalTrigger = () => wrapper.find('.js-component-links-modal-trigger');
afterEach(() => {
wrapper.destroy();
});
it('intersperses the list of licenses correctly', () => {
factory();
const intersperseInstance = wrapper.find(GlIntersperse);
expect(intersperseInstance.exists()).toBe(true);
expect(intersperseInstance.attributes('lastseparator')).toBe(' and ');
});
it.each([3, 5, 8, 13])('limits the number of visible licenses to 2', numComponents => {
factory({ numComponents });
expect(findComponentListItems().length).toBe(VISIBLE_COMPONENT_COUNT);
});
it.each`
numComponents | numComponentsWithUrl | expectedNumVisibleLinks | expectedNumModalLinks
${2} | ${2} | ${2} | ${0}
${3} | ${2} | ${2} | ${2}
${5} | ${2} | ${2} | ${2}
${2} | ${1} | ${1} | ${0}
${3} | ${1} | ${1} | ${1}
${5} | ${0} | ${0} | ${0}
`(
'contains the correct number of links given $numComponents components where $numComponentsWithUrl contain a url',
({ numComponents, numComponentsWithUrl, expectedNumVisibleLinks, expectedNumModalLinks }) => {
factory({ numComponents, numComponentsWithUrl });
expect(findComponentsList().findAll(GlLink).length).toBe(expectedNumVisibleLinks);
// findModal() is an empty wrapper if we have less than VISIBLE_COMPONENT_COUNT
if (numComponents > VISIBLE_COMPONENT_COUNT) {
expect(findModal().findAll(GlLink).length).toBe(expectedNumModalLinks);
} else {
expect(findModal().exists()).toBe(false);
}
},
);
it('sets all links to open in new windows/tabs', () => {
factory({ numComponents: 8, numComponentsWithUrl: 8 });
const links = wrapper.findAll(GlLink);
links.wrappers.forEach(link => {
expect(link.attributes('target')).toBe('_blank');
});
});
it.each`
numComponents | expectedNumExceedingComponents
${3} | ${1}
${5} | ${3}
${8} | ${6}
`(
'shows the number of licenses that are included in the modal',
({ numComponents, expectedNumExceedingComponents }) => {
factory({ numComponents });
expect(findModalTrigger().text()).toBe(`${expectedNumExceedingComponents} more`);
},
);
it.each`
numComponents | expectedNumModals
${0} | ${0}
${1} | ${0}
${2} | ${0}
${3} | ${1}
${5} | ${1}
${8} | ${1}
`(
'contains $expectedNumModals modal when $numComponents components are given',
({ numComponents, expectedNumModals }) => {
factory({ numComponents, expectedNumModals });
expect(wrapper.findAll(GlModal).length).toBe(expectedNumModals);
},
);
it('opens the modal when the trigger gets clicked', () => {
factory({ numComponents: 3 });
const modalId = wrapper.find(GlModal).props('modalId');
const modalTrigger = findModalTrigger();
const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit');
modalTrigger.trigger('click');
expect(rootEmit.mock.calls[0]).toContain(modalId);
});
it('assigns a unique modal-id to each of its instances', () => {
const numComponents = 4;
const usedModalIds = [];
while (usedModalIds.length < 10) {
factory({ numComponents });
const modalId = wrapper.find(GlModal).props('modalId');
expect(usedModalIds).not.toContain(modalId);
usedModalIds.push(modalId);
}
});
it('uses the title as the modal-title', () => {
const title = 'test-component';
factory({ numComponents: 3, title });
expect(wrapper.find(GlModal).attributes('title')).toEqual(title);
});
it('assigns the correct action button text to the modal', () => {
factory({ numComponents: 3 });
expect(wrapper.find(GlModal).attributes('ok-title')).toEqual('Close');
});
it.each`
numComponents | expectedComponentsInModal
${1} | ${0}
${2} | ${0}
${3} | ${3}
${5} | ${5}
${8} | ${8}
`('contains the correct modal content', ({ numComponents, expectedComponentsInModal }) => {
factory({ numComponents });
expect(findModalItem().wrappers).toHaveLength(expectedComponentsInModal);
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLink, GlSkeletonLoading } from '@gitlab/ui';
import LicenseComponentLinks from 'ee/project_licenses/components/license_component_links.vue';
import LicensesTableRow from 'ee/project_licenses/components/licenses_table_row.vue';
import { makeLicense } from './utils';
describe('LicensesTableRow component', () => {
const localVue = createLocalVue();
let wrapper;
let license;
const factory = (propsData = {}) => {
wrapper = shallowMount(localVue.extend(LicensesTableRow), {
localVue,
sync: false,
propsData,
});
};
const findLoading = () => wrapper.find(GlSkeletonLoading);
const findContent = () => wrapper.find('.js-license-row');
const findNameSeciton = () => findContent().find('.section-30');
const findComponentSection = () => findContent().find('.section-70');
beforeEach(() => {
license = makeLicense();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
desc | props
${'when passed no props'} | ${{}}
${'when loading'} | ${{ isLoading: true }}
`('$desc', ({ props }) => {
beforeEach(() => {
factory(props);
});
it('shows the skeleton loading component', () => {
const loading = findLoading();
expect(loading.exists()).toBe(true);
expect(loading.props('lines')).toEqual(1);
});
it('does not show the content', () => {
const content = findContent();
expect(content.exists()).toBe(false);
});
});
describe('when a license has url and components', () => {
beforeEach(() => {
factory({
isLoading: false,
license,
});
});
it('shows name', () => {
const nameLink = findNameSeciton().find(GlLink);
expect(nameLink.exists()).toBe(true);
expect(nameLink.attributes('href')).toEqual(license.url);
expect(nameLink.text()).toEqual(license.name);
});
it('shows components', () => {
const componentLinks = findComponentSection().find(LicenseComponentLinks);
expect(componentLinks.exists()).toBe(true);
expect(componentLinks.props()).toEqual(
expect.objectContaining({
components: license.components,
title: license.name,
}),
);
});
});
describe('with a license without a url', () => {
beforeEach(() => {
license.url = null;
factory({
isLoading: false,
license,
});
});
it('does not show url link for name', () => {
const nameSection = findNameSeciton();
expect(nameSection.text()).toContain(license.name);
expect(nameSection.find(GlLink).exists()).toBe(false);
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import LicensesTable from 'ee/project_licenses/components/licenses_table.vue';
import LicensesTableRow from 'ee/project_licenses/components/licenses_table_row.vue';
import { makeLicense } from './utils';
describe('LicensesTable component', () => {
const localVue = createLocalVue();
let wrapper;
const factory = (propsData = {}) => {
wrapper = shallowMount(localVue.extend(LicensesTable), {
localVue,
sync: false,
propsData: { ...propsData },
});
};
const findTableRowHeader = () => wrapper.find('.table-row-header');
const findRows = () => wrapper.findAll(LicensesTableRow);
afterEach(() => {
wrapper.destroy();
});
describe('given an empty list of licenses', () => {
beforeEach(() => {
factory({
licenses: [],
isLoading: false,
});
});
it('renders the table headers', () => {
expect(findTableRowHeader().element).toMatchSnapshot();
});
it('renders the empty license table', () => {
expect(findRows().length).toEqual(0);
});
});
[true, false].forEach(isLoading => {
describe(`given a list of licenses (${isLoading ? 'loading' : 'loaded'})`, () => {
let licenses;
beforeEach(() => {
licenses = [makeLicense(), makeLicense({ name: 'foo' })];
factory({
licenses,
isLoading,
});
});
it('renders the table headers', () => {
expect(findTableRowHeader().element).toMatchSnapshot();
});
it('passes the correct props to the table rows', () => {
expect(findRows().length).toEqual(licenses.length);
expect(findRows().wrappers.map(x => x.props())).toEqual(
licenses.map(license => ({
license,
isLoading,
})),
);
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import createStore from 'ee/project_licenses/store';
import LicensesTable from 'ee/project_licenses/components/licenses_table.vue';
import { LICENSE_LIST } from 'ee/project_licenses/store/constants';
import PaginatedLicensesTable from 'ee/project_licenses/components/paginated_licenses_table.vue';
import Pagination from '~/vue_shared/components/pagination_links.vue';
import mockLicensesResponse from '../store/modules/list/data/mock_licenses';
describe('PaginatedLicensesTable component', () => {
const localVue = createLocalVue();
const namespace = LICENSE_LIST;
let store;
let wrapper;
const factory = () => {
store = createStore();
wrapper = shallowMount(localVue.extend(PaginatedLicensesTable), {
localVue,
store,
sync: false,
});
};
const expectComponentWithProps = (Component, props = {}) => {
const componentWrapper = wrapper.find(Component);
expect(componentWrapper.isVisible()).toBe(true);
expect(componentWrapper.props()).toEqual(expect.objectContaining(props));
};
beforeEach(() => {
factory();
store.dispatch(`${namespace}/receiveLicensesSuccess`, {
data: mockLicensesResponse,
headers: { 'X-Total': mockLicensesResponse.licenses.length },
});
jest.spyOn(store, 'dispatch').mockImplementation();
return wrapper.vm.$nextTick();
});
afterEach(() => {
wrapper.destroy();
});
it('passes the correct props to the licenses table', () => {
expectComponentWithProps(LicensesTable, {
licenses: mockLicensesResponse.licenses,
isLoading: store.state[namespace].isLoading,
});
});
it('passes the correct props to the pagination', () => {
expectComponentWithProps(Pagination, {
change: wrapper.vm.fetchPage,
pageInfo: store.state[namespace].pageInfo,
});
});
it('has a fetchPage method which dispatches the correct action', () => {
const page = 2;
wrapper.vm.fetchPage(page);
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(`${namespace}/fetchLicenses`, { page });
});
describe.each`
context | isLoading | errorLoading | isListEmpty | initialized
${'the list is loading'} | ${true} | ${false} | ${false} | ${false}
${'the list is empty (initalized)'} | ${false} | ${false} | ${true} | ${true}
${'the list is empty'} | ${false} | ${false} | ${true} | ${false}
${'there was an error loading'} | ${false} | ${true} | ${false} | ${false}
`('given $context', ({ isLoading, errorLoading, isListEmpty, initialized }) => {
let moduleState;
beforeEach(() => {
moduleState = Object.assign(store.state[namespace], {
isLoading,
errorLoading,
initialized,
});
if (isListEmpty) {
moduleState.licenses = [];
moduleState.pageInfo.total = 0;
}
return wrapper.vm.$nextTick();
});
// See https://github.com/jest-community/eslint-plugin-jest/issues/229 for
// a similar reason for disabling the rule on the next line
// eslint-disable-next-line jest/no-identical-title
it('passes the correct props to the licenses table', () => {
expectComponentWithProps(LicensesTable, {
licenses: moduleState.licenses,
isLoading,
});
});
it('does not render pagination', () => {
expect(wrapper.find(Pagination).exists()).toBe(false);
});
});
});
// eslint-disable-next-line import/prefer-default-export
export const makeLicense = (changes = {}) => ({
name: 'Apache 2.0',
url: 'http://www.apache.org/licenses/LICENSE-2.0.txt',
components: [
{
name: 'ejs',
blob_path: null,
},
{
name: 'saml2-js',
blob_path: null,
},
],
...changes,
});
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/project_licenses/store/modules/list/actions';
import * as types from 'ee/project_licenses/store/modules/list/mutation_types';
import getInitialState from 'ee/project_licenses/store/modules/list/state';
import createFlash from '~/flash';
import { FETCH_ERROR_MESSAGE } from 'ee/project_licenses/store/modules/list/constants';
import mockLicensesResponse from './data/mock_licenses';
jest.mock('~/flash', () => jest.fn());
describe('Licenses actions', () => {
const pageInfo = {
page: 3,
nextPage: 2,
previousPage: 1,
perPage: 20,
total: 100,
totalPages: 5,
};
const headers = {
'X-Next-Page': pageInfo.nextPage,
'X-Page': pageInfo.page,
'X-Per-Page': pageInfo.perPage,
'X-Prev-Page': pageInfo.previousPage,
'X-Total': pageInfo.total,
'X-Total-Pages': pageInfo.totalPages,
};
afterEach(() => {
createFlash.mockClear();
});
describe('setLicensesEndpoint', () => {
it('commits the SET_LICENSES_ENDPOINT mutation', () =>
testAction(
actions.setLicensesEndpoint,
TEST_HOST,
getInitialState(),
[
{
type: types.SET_LICENSES_ENDPOINT,
payload: TEST_HOST,
},
],
[],
));
});
describe('requestLicenses', () => {
it('commits the REQUEST_LICENSES mutation', () =>
testAction(
actions.requestLicenses,
undefined,
getInitialState(),
[
{
type: types.REQUEST_LICENSES,
},
],
[],
));
});
describe('receiveLicensesSuccess', () => {
it('commits the RECEIVE_LICENSES_SUCCESS mutation', () =>
testAction(
actions.receiveLicensesSuccess,
{ headers, data: mockLicensesResponse },
getInitialState(),
[
{
type: types.RECEIVE_LICENSES_SUCCESS,
payload: {
licenses: mockLicensesResponse.licenses,
reportInfo: mockLicensesResponse.report,
pageInfo,
},
},
],
[],
));
});
describe('receiveLicensesError', () => {
it('commits the RECEIVE_LICENSES_ERROR mutation', () => {
const error = { error: true };
return testAction(
actions.receiveLicensesError,
error,
getInitialState(),
[
{
type: types.RECEIVE_LICENSES_ERROR,
},
],
[],
).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR_MESSAGE);
});
});
});
describe('fetchLicenses', () => {
let state;
let mock;
beforeEach(() => {
state = getInitialState();
state.endpoint = `${TEST_HOST}/licenses`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('when endpoint is empty', () => {
beforeEach(() => {
state.endpoint = '';
});
it('returns a rejected promise', () =>
expect(actions.fetchLicenses({ state })).rejects.toEqual(
new Error('No endpoint provided'),
));
});
describe('on success', () => {
describe('given no params', () => {
beforeEach(() => {
state.pageInfo = { ...pageInfo };
const paramsDefault = {
page: state.pageInfo.page,
per_page: 10,
};
mock
.onGet(state.endpoint, { params: paramsDefault })
.replyOnce(200, mockLicensesResponse, headers);
});
it('uses default params from state', () =>
testAction(
actions.fetchLicenses,
undefined,
state,
[],
[
{
type: 'requestLicenses',
},
{
type: 'receiveLicensesSuccess',
payload: expect.objectContaining({ data: mockLicensesResponse, headers }),
},
],
));
});
describe('given params', () => {
const paramsGiven = {
page: 4,
};
const paramsSent = {
...paramsGiven,
per_page: 10,
};
beforeEach(() => {
mock
.onGet(state.endpoint, { params: paramsSent })
.replyOnce(200, mockLicensesResponse, headers);
});
it('overrides default params', () =>
testAction(
actions.fetchLicenses,
paramsGiven,
state,
[],
[
{
type: 'requestLicenses',
},
{
type: 'receiveLicensesSuccess',
payload: expect.objectContaining({ data: mockLicensesResponse, headers }),
},
],
));
});
});
describe('given a response error', () => {
beforeEach(() => {
mock.onGet(state.endpoint).replyOnce([500]);
});
it('dispatches the receiveLicensesError action and creates a flash', () =>
testAction(
actions.fetchLicenses,
undefined,
state,
[],
[
{
type: 'requestLicenses',
},
{
type: 'receiveLicensesError',
payload: expect.any(Error),
},
],
));
});
});
});
{
"report": {
"status": "ok",
"job_path": "/auto-remediation-group/yarn-remediation/builds/144",
"generated_at": "2019-10-24T15:06:46.176Z"
},
"licenses": [
{
"name": "(BSD-3-Clause OR GPL-2.0)",
"url": null,
"components": [
{
"name": "node-forge",
"blob_path": null
}
]
},
{
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"components": [
{
"name": "ejs",
"blob_path": null
},
{
"name": "saml2-js",
"blob_path": null
}
]
},
{
"name": "ISC",
"url": "http://en.wikipedia.org/wiki/ISC_license",
"components": [
{
"name": "sax",
"blob_path": null
}
]
},
{
"name": "MIT",
"url": "http://opensource.org/licenses/mit-license",
"components": [
{
"name": "async",
"blob_path": null
},
{
"name": "debug",
"blob_path": null
},
{
"name": "define-properties",
"blob_path": null
},
{
"name": "es-abstract",
"blob_path": null
},
{
"name": "es-to-primitive",
"blob_path": null
},
{
"name": "function-bind",
"blob_path": null
},
{
"name": "has",
"blob_path": null
},
{
"name": "has-symbols",
"blob_path": null
},
{
"name": "is-callable",
"blob_path": null
},
{
"name": "is-date-object",
"blob_path": null
},
{
"name": "is-regex",
"blob_path": null
},
{
"name": "is-symbol",
"blob_path": null
},
{
"name": "lodash",
"blob_path": null
},
{
"name": "lodash-node",
"blob_path": null
},
{
"name": "ms",
"blob_path": null
},
{
"name": "object-inspect",
"blob_path": null
},
{
"name": "object-keys",
"blob_path": null
},
{
"name": "object.getownpropertydescriptors",
"blob_path": null
},
{
"name": "string.prototype.trimleft",
"blob_path": null
},
{
"name": "string.prototype.trimright",
"blob_path": null
},
{
"name": "underscore",
"blob_path": null
},
{
"name": "util.promisify",
"blob_path": null
},
{
"name": "xml-crypto",
"blob_path": null
},
{
"name": "xml-encryption",
"blob_path": null
},
{
"name": "xml2js",
"blob_path": null
},
{
"name": "xmlbuilder",
"blob_path": null
},
{
"name": "xpath",
"blob_path": null
},
{
"name": "xpath.js",
"blob_path": null
}
]
},
{
"name": "MIT*",
"url": null,
"components": [
{
"name": "xmldom",
"blob_path": null
}
]
}
]
}
import * as getters from 'ee/project_licenses/store/modules/list/getters';
import { REPORT_STATUS } from 'ee/project_licenses/store/modules/list/constants';
describe('Licenses getters', () => {
describe.each`
getterName | reportStatus | outcome
${'isJobSetUp'} | ${REPORT_STATUS.jobNotSetUp} | ${false}
${'isJobSetUp'} | ${REPORT_STATUS.ok} | ${true}
${'isJobFailed'} | ${REPORT_STATUS.jobFailed} | ${true}
${'isJobFailed'} | ${REPORT_STATUS.noLicenses} | ${true}
${'isJobFailed'} | ${REPORT_STATUS.incomplete} | ${true}
${'isJobFailed'} | ${REPORT_STATUS.ok} | ${false}
`('$getterName when report status is $reportStatus', ({ getterName, reportStatus, outcome }) => {
it(`returns ${outcome}`, () => {
expect(
getters[getterName]({
reportInfo: {
status: reportStatus,
},
}),
).toBe(outcome);
});
});
});
import * as types from 'ee/project_licenses/store/modules/list/mutation_types';
import mutations from 'ee/project_licenses/store/modules/list/mutations';
import getInitialState from 'ee/project_licenses/store/modules/list/state';
import { REPORT_STATUS } from 'ee/project_licenses/store/modules/list/constants';
import { TEST_HOST } from 'helpers/test_constants';
describe('Licenses mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe(types.SET_LICENSES_ENDPOINT, () => {
it('sets the endpoint and download endpoint', () => {
mutations[types.SET_LICENSES_ENDPOINT](state, TEST_HOST);
expect(state.endpoint).toBe(TEST_HOST);
});
});
describe(types.REQUEST_LICENSES, () => {
beforeEach(() => {
mutations[types.REQUEST_LICENSES](state);
});
it('correctly mutates the state', () => {
expect(state.isLoading).toBe(true);
expect(state.errorLoading).toBe(false);
});
});
describe(types.RECEIVE_LICENSES_SUCCESS, () => {
const licenses = [];
const pageInfo = {};
const reportInfo = {
status: REPORT_STATUS.jobFailed,
job_path: 'foo',
};
beforeEach(() => {
mutations[types.RECEIVE_LICENSES_SUCCESS](state, { licenses, reportInfo, pageInfo });
});
it('correctly mutates the state', () => {
expect(state.isLoading).toBe(false);
expect(state.errorLoading).toBe(false);
expect(state.licenses).toBe(licenses);
expect(state.pageInfo).toBe(pageInfo);
expect(state.initialized).toBe(true);
expect(state.reportInfo).toEqual({
status: REPORT_STATUS.jobFailed,
jobPath: 'foo',
});
});
});
describe(types.RECEIVE_LICENSES_ERROR, () => {
beforeEach(() => {
mutations[types.RECEIVE_LICENSES_ERROR](state);
});
it('correctly mutates the state', () => {
expect(state.isLoading).toBe(false);
expect(state.errorLoading).toBe(true);
expect(state.licenses).toEqual([]);
expect(state.pageInfo).toEqual({ total: 0 });
expect(state.initialized).toBe(true);
expect(state.reportInfo).toEqual({
generatedAt: '',
status: REPORT_STATUS.ok,
jobPath: '',
});
});
});
});
......@@ -10420,6 +10420,36 @@ msgstr ""
msgid "Licenses"
msgstr ""
msgid "Licenses|%{remainingComponentsCount} more"
msgstr ""
msgid "Licenses|Component"
msgstr ""
msgid "Licenses|Components"
msgstr ""
msgid "Licenses|Displays licenses detected in the project, based on the %{linkStart}latest pipeline%{linkEnd} scan"
msgstr ""
msgid "Licenses|Error fetching the license list. Please check your network connection and try again."
msgstr ""
msgid "Licenses|Learn more about license compliance"
msgstr ""
msgid "Licenses|License Compliance"
msgstr ""
msgid "Licenses|Name"
msgstr ""
msgid "Licenses|The license list details information about the licenses used within your project."
msgstr ""
msgid "Licenses|View license details for your project"
msgstr ""
msgid "License|Buy license"
msgstr ""
......@@ -11635,6 +11665,9 @@ msgstr ""
msgid "No due date"
msgstr ""
msgid "No endpoint provided"
msgstr ""
msgid "No errors to display."
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