Commit c0914223 authored by Paul Slaughter's avatar Paul Slaughter
Browse files

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

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

See merge request gitlab-org/gitlab!18934
parents f8292c71 13901b15
......@@ -29,6 +29,8 @@
.border-color-default { border-color: $border-color; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
 
.mh-50vh { max-height: 50vh; }
.gl-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
.gl-bg-blue-500 { @include gl-bg-blue-500; }
import Vue from 'vue';
import { __ } from '~/locale';
import initProjectLicensesApp from 'ee/project_licenses';
 
if (gon.features && gon.features.licensesList) {
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
el: '#js-licenses-app',
render(createElement) {
return createElement('h1', __('License Compliance'));
},
}),
);
}
document.addEventListener('DOMContentLoaded', initProjectLicensesApp);
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlEmptyState, GlLoadingIcon, GlLink, GlIcon } from '@gitlab/ui';
import { LICENSE_LIST } from '../store/constants';
import PaginatedLicensesTable from './paginated_licenses_table.vue';
import PipelineInfo from './pipeline_info.vue';
export default {
name: 'ProjectLicensesApp',
components: {
GlEmptyState,
GlLoadingIcon,
GlLink,
PaginatedLicensesTable,
PipelineInfo,
GlIcon,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
documentationPath: {
type: String,
required: true,
},
},
computed: {
...mapState(LICENSE_LIST, ['initialized', 'reportInfo']),
...mapGetters(LICENSE_LIST, ['isJobSetUp', 'isJobFailed']),
hasEmptyState() {
return Boolean(!this.isJobSetUp || this.isJobFailed);
},
},
created() {
this.fetchLicenses();
},
methods: {
...mapActions(LICENSE_LIST, ['fetchLicenses']),
},
};
</script>
<template>
<gl-loading-icon v-if="!initialized" size="md" class="mt-4" />
<gl-empty-state
v-else-if="hasEmptyState"
:title="s__('Licenses|View license details for your project')"
:description="
s__(
'Licenses|The license list details information about the licenses used within your project.',
)
"
:svg-path="emptyStateSvgPath"
:primary-button-link="documentationPath"
:primary-button-text="s__('Licenses|Learn more about license compliance')"
/>
<div v-else>
<h2 class="h4">
{{ s__('Licenses|License Compliance') }}
<gl-link :href="documentationPath" class="vertical-align-middle" target="_blank">
<gl-icon name="question" />
</gl-link>
</h2>
<pipeline-info :path="reportInfo.jobPath" :timestamp="reportInfo.generatedAt" />
<paginated-licenses-table class="mt-3" />
</div>
</template>
<script>
import { uniqueId } from 'underscore';
import { sprintf, s__ } from '~/locale';
import { GlLink, GlIntersperse, GlModal, GlButton, GlModalDirective } from '@gitlab/ui';
const MODAL_ID_PREFIX = 'license-component-link-modal-';
export const VISIBLE_COMPONENT_COUNT = 2;
export default {
components: {
GlIntersperse,
GlLink,
GlButton,
GlModal,
},
directives: {
GlModalDirective,
},
props: {
title: {
type: String,
required: true,
},
components: {
type: Array,
required: true,
},
},
computed: {
modalId() {
return uniqueId(MODAL_ID_PREFIX);
},
visibleComponents() {
return this.components.slice(0, VISIBLE_COMPONENT_COUNT);
},
remainingComponentsCount() {
return Math.max(0, this.components.length - VISIBLE_COMPONENT_COUNT);
},
hasComponentsInModal() {
return this.remainingComponentsCount > 0;
},
lastSeparator() {
return ` ${s__('SeriesFinalConjunction|and')} `;
},
modalButtonText() {
const { remainingComponentsCount } = this;
return sprintf(s__('Licenses|%{remainingComponentsCount} more'), {
remainingComponentsCount,
});
},
modalActionText() {
return s__('Modal|Close');
},
},
};
</script>
<template>
<div>
<gl-intersperse :last-separator="lastSeparator" class="js-component-links-component-list">
<span
v-for="(component, index) in visibleComponents"
:key="index"
class="js-component-links-component-list-item"
>
<gl-link v-if="component.blob_path" :href="component.blob_path" target="_blank">{{
component.name
}}</gl-link>
<template v-else>{{ component.name }}</template>
</span>
<gl-button
v-if="hasComponentsInModal"
v-gl-modal-directive="modalId"
variant="link"
class="align-baseline js-component-links-modal-trigger"
>
{{ modalButtonText }}
</gl-button>
</gl-intersperse>
<gl-modal
v-if="hasComponentsInModal"
:title="title"
:modal-id="modalId"
:ok-title="modalActionText"
ok-only
ok-variant="secondary"
>
<h5>{{ s__('Licenses|Components') }}</h5>
<ul class="list-unstyled overflow-auto mh-50vh">
<li
v-for="component in components"
:key="component.name"
class="js-component-links-modal-item"
>
<gl-link v-if="component.blob_path" :href="component.blob_path" target="_blank">{{
component.name
}}</gl-link>
<span v-else>{{ component.name }}</span>
</li>
</ul>
</gl-modal>
</div>
</template>
<script>
import { s__ } from '~/locale';
import LicensesTableRow from './licenses_table_row.vue';
export default {
name: 'LicensesTable',
components: {
LicensesTableRow,
},
props: {
licenses: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
data() {
return {
tableHeaders: [
{ className: 'section-30', label: s__('Licenses|Name') },
{ className: 'section-70', label: s__('Licenses|Component') },
],
};
},
};
</script>
<template>
<div>
<div class="gl-responsive-table-row table-row-header text-2 bg-secondary-50 px-2" role="row">
<div
v-for="(header, index) in tableHeaders"
:key="index"
class="table-section"
:class="header.className"
role="rowheader"
>
{{ header.label }}
</div>
</div>
<licenses-table-row
v-for="(license, index) in licenses"
:key="index"
:license="license"
:is-loading="isLoading"
/>
</div>
</template>
<script>
import { GlLink, GlSkeletonLoading } from '@gitlab/ui';
import LicenseComponentLinks from './license_component_links.vue';
export default {
name: 'LicensesTableRow',
components: {
LicenseComponentLinks,
GlLink,
GlSkeletonLoading,
},
props: {
license: {
type: Object,
required: false,
default: null,
},
isLoading: {
type: Boolean,
required: false,
default: true,
},
},
};
</script>
<template>
<div class="gl-responsive-table-row flex-md-column align-items-md-stretch px-2">
<gl-skeleton-loading
v-if="isLoading"
:lines="1"
class="d-flex flex-column justify-content-center h-auto"
/>
<div v-else class="d-md-flex align-items-baseline js-license-row">
<!-- Name-->
<div class="table-section section-30 section-wrap pr-md-3">
<div class="table-mobile-header" role="rowheader">
{{ s__('Licenses|Name') }}
</div>
<div class="table-mobile-content">
<gl-link v-if="license.url" :href="license.url" target="_blank">{{
license.name
}}</gl-link>
<template v-else>{{ license.name }}</template>
</div>
</div>
<!-- Component -->
<div class="table-section section-70 section-wrap pr-md-3">
<div class="table-mobile-header" role="rowheader">{{ s__('Licenses|Component') }}</div>
<div class="table-mobile-content">
<license-component-links :components="license.components" :title="license.name" />
</div>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import Pagination from '~/vue_shared/components/pagination_links.vue';
import LicensesTable from './licenses_table.vue';
import { LICENSE_LIST } from '../store/constants';
export default {
name: 'PaginatedLicensesTable',
components: {
LicensesTable,
Pagination,
},
computed: {
...mapState(LICENSE_LIST, ['licenses', 'isLoading', 'initialized', 'pageInfo']),
shouldShowPagination() {
const { initialized, pageInfo } = this;
return Boolean(initialized && pageInfo && pageInfo.total);
},
},
methods: {
...mapActions(LICENSE_LIST, ['fetchLicenses']),
fetchPage(page) {
return this.fetchLicenses({ page });
},
},
};
</script>
<template>
<div>
<licenses-table :licenses="licenses" :is-loading="isLoading" />
<pagination
v-if="shouldShowPagination"
:change="fetchPage"
:page-info="pageInfo"
class="justify-content-center mt-3"
/>
</div>
</template>
<script>
import { escape } from 'underscore';
import { s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'PipelineInfo',
components: {
TimeAgoTooltip,
},
props: {
path: {
required: true,
type: String,
},
timestamp: {
required: true,
type: String,
},
},
computed: {
pipelineText() {
const { path } = this;
const body = s__(
'Licenses|Displays licenses detected in the project, based on the %{linkStart}latest pipeline%{linkEnd} scan',
);
const linkStart = path
? `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`
: '';
const linkEnd = path ? '</a>' : '';
return sprintf(body, { linkStart, linkEnd }, false);
},
hasFullPipelineText() {
return Boolean(this.path && this.timestamp);
},
},
};
</script>
<template>
<span v-if="hasFullPipelineText">
<span v-html="pipelineText"></span>
<span></span>
<time-ago-tooltip :time="timestamp" />
</span>
<span v-else v-html="pipelineText"></span>
</template>
import Vue from 'vue';
import ProjectLicensesApp from './components/app.vue';
import createStore from './store';
import { LICENSE_LIST } from './store/constants';
export default () => {
const el = document.querySelector('#js-licenses-app');
const { endpoint, emptyStateSvgPath, documentationPath } = el.dataset;
const store = createStore();
store.dispatch(`${LICENSE_LIST}/setLicensesEndpoint`, endpoint);
return new Vue({
el,
store,
components: {
ProjectLicensesApp,
},
render(createElement) {
return createElement(ProjectLicensesApp, {
props: {
emptyStateSvgPath,
documentationPath,
},
});
},
});
};
/* eslint-disable import/prefer-default-export */
export const LICENSE_LIST = 'licenseList';
import Vue from 'vue';
import Vuex from 'vuex';
import listModule from './modules/list';
import { LICENSE_LIST } from './constants';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
modules: {
[LICENSE_LIST]: listModule(),
},
});
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { FETCH_ERROR_MESSAGE } from './constants';
import * as types from './mutation_types';
export const setLicensesEndpoint = ({ commit }, endpoint) =>
commit(types.SET_LICENSES_ENDPOINT, endpoint);
export const fetchLicenses = ({ state, dispatch }, params = {}) => {
if (!state.endpoint) {
return Promise.reject(new Error(__('No endpoint provided')));
}
dispatch('requestLicenses');
return axios
.get(state.endpoint, {
params: {
per_page: 10,
page: state.pageInfo.page || 1,
...params,
},
})
.then(response => {
dispatch('receiveLicensesSuccess', response);
})
.catch(error => {
dispatch('receiveLicensesError', error);
});
};
export const requestLicenses = ({ commit }) => commit(types.REQUEST_LICENSES);
export const receiveLicensesSuccess = ({ commit }, { headers, data }) => {
const normalizedHeaders = normalizeHeaders(headers);
const pageInfo = parseIntPagination(normalizedHeaders);
const { licenses, report: reportInfo } = data;
commit(types.RECEIVE_LICENSES_SUCCESS, { licenses, reportInfo, pageInfo });
};
export const receiveLicensesError = ({ commit }) => {
commit(types.RECEIVE_LICENSES_ERROR);
createFlash(FETCH_ERROR_MESSAGE);
};
import { s__ } from '~/locale';
export const REPORT_STATUS = {
ok: 'ok',
jobNotSetUp: 'job_not_set_up',
jobFailed: 'job_failed',
noLicenses: 'no_licenses',
incomplete: 'no_license_files',
};
export const FETCH_ERROR_MESSAGE = s__(
'Licenses|Error fetching the license list. Please check your network connection and try again.',
);
import { REPORT_STATUS } from './constants';
export const isJobSetUp = state => state.reportInfo.status !== REPORT_STATUS.jobNotSetUp;
export const isJobFailed = state =>
[REPORT_STATUS.jobFailed, REPORT_STATUS.noLicenses, REPORT_STATUS.incomplete].includes(
state.reportInfo.status,
);
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
actions,
getters,
mutations,
state,
});
export const SET_LICENSES_ENDPOINT = 'SET_LICENSES_ENDPOINT';
export const REQUEST_LICENSES = 'REQUEST_LICENSES';
export const RECEIVE_LICENSES_SUCCESS = 'RECEIVE_LICENSES_SUCCESS';
export const RECEIVE_LICENSES_ERROR = 'RECEIVE_LICENSES_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_LICENSES_ENDPOINT](state, payload) {
state.endpoint = payload;
},
[types.REQUEST_LICENSES](state) {
state.isLoading = true;
state.errorLoading = false;
},
[types.RECEIVE_LICENSES_SUCCESS](state, { licenses, reportInfo, pageInfo }) {
state.licenses = licenses;
state.pageInfo = pageInfo;
state.isLoading = false;
state.errorLoading = false;
state.initialized = true;
state.reportInfo = {
status: reportInfo.status,
jobPath: reportInfo.job_path,
generatedAt: reportInfo.generated_at,
};
},
[types.RECEIVE_LICENSES_ERROR](state) {
state.isLoading = false;
state.errorLoading = true;
state.initialized = true;
},
};
import { REPORT_STATUS } from './constants';
export default () => ({
endpoint: '',