Commit 13901b15 authored by Fernando Arias's avatar Fernando Arias Committed by Paul Slaughter

Add license list to nav

* Add feature flag and permissions check
* Add controller, route, and nav entries

Fix typo

Refactor routes and feature flag

* Change from license_list to licenses_list
* Change from /security/license-list to /licenses

Fix pipeline errors

* remove controller file whitesspace
* regenerate pot file

Fix specs and linter

Update project_nav_tab? call

Add missing new line

Fix haml linter

Code review changes

Update to License Compliance

Update page header

Remove unecessary before action

Remove database.yml.example

Creation of app and component directories

* Refactor app bootstrap

Add store

* Add Actions, Getters, Mutations and state

Save Changes

Working license fetch

Migrate to Vuex Module

Add table

Render table and row first pass

Add component column logic and modal

Update pagination rednering conditions

Add pipeline link/text and timeago text

Add question mark

Fix unconfigured state SVG

Remove empty files and add action spec

Update action spec and add mutation specs

Add getters spec

Run prettier and linter

Add changelog

Tweak specs and add pagianted license list spec

Add license table spec and snapshots

Fix question icon alignment and mobile column name

Add licence table row spec and snapshots

Add component link specs

Run prettier and linter

Run prettier and linter

Namespace translatons and update pot file

Tweak icon markup

Code feedback tweaks

Refactor i18n translations

Use gl-icon

Fix header tag

Update to hasJobSetup

Update to hasLicenses

Change to hasJobFailed

First batch of maintainer review changes

* Move changelog to EE
* Change h5 to h3 for accessibility
* Line-wrap haml template for vue app container

Move LICENSE_LIST constant out of module

* Refactor imports

Move changelog to EE and revert to isJobSetUp

Code review changes

* Update license copy and update POT file
* Remove unused class
* Remove icon size

Refactor modal and spec

* Remove wrapping div
* Update unit tests
* Update remainingComponentsCount function to use Math.max

Fix potential security issues

* Sanitize url path
* Secure link with rel="noopener noreferrer"
* Update to rowheader

Remove unused getters

Refactor getters

* Refactor simple getters to use mapState

Remove unused empty file

Apply suggestion to pipeline_info.vue

Apply suggestion to mutation_types.js

Apply suggestion to actions.js

Apply suggestion to license_component_links_spec.js

More fixes

* Fall back to using array index as key :-(
* Fix typo in unit test
* Reorder imports

Add mh-vh-50 utility class and scrollable modal

* Add utility class for half the viewport height
* Make scrollable area half the viewport height

Simplify licenses_table_row_spec factory

Update licenses_table_spec factory

* Remove optional parameter
* Update snapshot

Clean up paginated_licenses_table_spec

* Remove duplicate scenarios
* Clean up factory

Clean up action spec

* Remove uneeded payload
* Convert describe iterator into normal describe

Run pretttier and linter

Fix unit tests and run prettier

Fix action spec

* Update to test for rejected promise
* Update pot file

Additional code review changes

* Refactor height class max-vh-50
* Add mb-1 class to license compliance header
* Remove unit test, test case that isn't possible
* Add test case for license compliance job status

Update utility class name

Fix license compliance route

* Undo route change that originally put it under
projects/-/licenses

Apply suggestion to
app/assets/stylesheets/utilities.scss

Apply suggestion to
ee/app/views/projects/licenses/show.html.haml
parent f8292c71
...@@ -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