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

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 @@
.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,