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 @@ ...@@ -29,6 +29,8 @@
.border-color-default { border-color: $border-color; } .border-color-default { border-color: $border-color; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } .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-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-64 { height: 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; } .gl-bg-blue-500 { @include gl-bg-blue-500; }
import Vue from 'vue'; import initProjectLicensesApp from 'ee/project_licenses';
import { __ } from '~/locale';
if (gon.features && gon.features.licensesList) { document.addEventListener('DOMContentLoaded', initProjectLicensesApp);
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
el: '#js-licenses-app',
render(createElement) {
return createElement('h1', __('License Compliance'));
},
}),
);
}
<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 @@ ...@@ -3,9 +3,5 @@
module Projects module Projects
class LicensesController < Projects::ApplicationController class LicensesController < Projects::ApplicationController
before_action :authorize_read_licenses_list! before_action :authorize_read_licenses_list!
before_action do
push_frontend_feature_flag(:licenses_list)
end
end end
end end
...@@ -6,10 +6,6 @@ module Projects ...@@ -6,10 +6,6 @@ module Projects
before_action :authorize_read_licenses_list! before_action :authorize_read_licenses_list!
before_action :authorize_admin_software_license_policy!, only: [:create, :update] before_action :authorize_admin_software_license_policy!, only: [:create, :update]
before_action do
push_frontend_feature_flag(:licenses_list)
end
def index def index
respond_to do |format| respond_to do |format|
format.json do format.json do
......
...@@ -45,7 +45,7 @@ module EE ...@@ -45,7 +45,7 @@ module EE
nav_tabs << :dependencies nav_tabs << :dependencies
end end
if ::Feature.enabled?(:licenses_list) && can?(current_user, :read_licenses_list, project) if can?(current_user, :read_licenses_list, project)
nav_tabs << :licenses nav_tabs << :licenses
end end
......
- breadcrumb_title _('License Compliance') - breadcrumb_title _('License Compliance')
- page_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 ...@@ -65,7 +65,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :dashboards, only: [:create] resources :dashboards, only: [:create]
end 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 resources :environments, only: [] do
member do member do
...@@ -193,7 +196,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -193,7 +196,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resource :configuration, only: [:show], controller: :configuration resource :configuration, only: [:show], controller: :configuration
resources :dependencies, only: [:index] resources :dependencies, only: [:index]
resources :licenses, only: [:index, :update]
# We have to define both legacy and new routes for Vulnerability Findings # We have to define both legacy and new routes for Vulnerability Findings
# because they are loaded upon application initialization and preloaded by # because they are loaded upon application initialization and preloaded by
# web server. # web server.
...@@ -214,8 +216,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -214,8 +216,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :vulnerability_feedback, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } resources :vulnerability_feedback, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ }
resource :dependencies, only: [:show] resource :dependencies, only: [:show]
resource :licenses, only: [:show]
# All new routes should go under /-/ scope. # All new routes should go under /-/ scope.
# Look for scope '-' at the top of the file. # Look for scope '-' at the top of the file.
# rubocop: enable Cop/PutProjectRoutesUnderScope # 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 "" ...@@ -10420,6 +10420,36 @@ msgstr ""
msgid "Licenses" msgid "Licenses"
msgstr "" 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" msgid "License|Buy license"
msgstr "" msgstr ""
...@@ -11635,6 +11665,9 @@ msgstr "" ...@@ -11635,6 +11665,9 @@ msgstr ""
msgid "No due date" msgid "No due date"
msgstr "" msgstr ""
msgid "No endpoint provided"
msgstr ""
msgid "No errors to display." msgid "No errors to display."
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