Commit ba975003 authored by Jiaan Louw's avatar Jiaan Louw Committed by Kushal Pandya

Refactor compliance dashboard into vue app

This refactors the HAML Compliance Dashboard
into a Vue application to upcomming features.
parent d72a6f47
...@@ -27,3 +27,5 @@ class MergeRequestSerializer < BaseSerializer ...@@ -27,3 +27,5 @@ class MergeRequestSerializer < BaseSerializer
super(merge_request, opts, entity) super(merge_request, opts, entity)
end end
end end
MergeRequestSerializer.prepend_if_ee('EE::MergeRequestSerializer')
import Vue from 'vue';
import ComplianceDashboard from './components/dashboard.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
export default () => {
const el = document.getElementById('js-compliance-dashboard');
const { mergeRequests, emptyStateSvgPath, isLastPage } = el.dataset;
return new Vue({
el,
render: createElement =>
createElement(ComplianceDashboard, {
props: {
mergeRequests: JSON.parse(mergeRequests),
isLastPage: parseBoolean(isLastPage),
emptyStateSvgPath,
},
}),
});
};
<script>
import { sprintf, __ } from '~/locale';
import { GlAvatarLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui';
import { PRESENTABLE_APPROVERS_LIMIT } from '../constants';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlAvatarLink,
GlAvatar,
},
props: {
approvers: {
type: Array,
required: true,
},
},
computed: {
hasApprovers() {
return this.approvers.length > 0;
},
approversToPresent() {
return this.approvers.slice(0, PRESENTABLE_APPROVERS_LIMIT);
},
amountOfApproversOverLimit() {
return this.approvers.length - PRESENTABLE_APPROVERS_LIMIT;
},
isApproversOverLimit() {
return this.amountOfApproversOverLimit > 0;
},
approversOverLimitString() {
return sprintf(__('+%{approvers} more approvers'), {
approvers: this.amountOfApproversOverLimit,
});
},
},
strings: {
approvedBy: __('Approved by: '),
noApprovers: __('No approvers'),
},
};
</script>
<template>
<li class="issuable-status d-flex approvers align-items-center">
<span class="gl-text-gray-700">
<template v-if="hasApprovers">
{{ $options.strings.approvedBy }}
</template>
<template v-else>
{{ $options.strings.noApprovers }}
</template>
</span>
<gl-avatar-link
v-for="approver in approversToPresent"
:key="approver.id"
:title="approver.name"
:href="approver.web_url"
:data-user-id="approver.id"
:data-name="approver.name"
class="d-flex align-items-center ml-2 author-link js-user-link "
>
<gl-avatar
:src="approver.avatar_url"
:entity-id="approver.id"
:entity-name="approver.name"
:size="16"
class="mr-1"
/>
<span>{{ approver.name }}</span>
</gl-avatar-link>
<span
v-if="isApproversOverLimit"
v-gl-tooltip.top="approversOverLimitString"
class="avatar-counter ml-2"
>+ {{ amountOfApproversOverLimit }}</span
>
</li>
</template>
<script>
import { __ } from '~/locale';
import MergeRequest from './merge_request.vue';
import EmptyState from './empty_state.vue';
import Pagination from './pagination.vue';
export default {
name: 'ComplianceDashboard',
components: {
MergeRequest,
EmptyState,
Pagination,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
mergeRequests: {
type: Array,
required: true,
},
isLastPage: {
type: Boolean,
required: true,
},
},
computed: {
hasMergeRequests() {
return this.mergeRequests.length > 0;
},
},
strings: {
heading: __('Compliance Dashboard'),
subheading: __('Here you will find recent merge request activity'),
},
};
</script>
<template>
<div v-if="hasMergeRequests" class="compliance-dashboard">
<header class="my-3">
<h4>{{ $options.strings.heading }}</h4>
<p>{{ $options.strings.subheading }}</p>
</header>
<ul class="content-list issuable-list issues-list">
<merge-request v-for="mr in mergeRequests" :key="mr.id" :merge-request="mr" />
</ul>
<pagination class="my-3" :is-last-page="isLastPage" />
</div>
<empty-state v-else :image-path="emptyStateSvgPath" />
</template>
<script>
import { __ } from '~/locale';
export default {
props: {
imagePath: {
type: String,
required: true,
},
},
strings: {
heading: __(
"Merge requests are a place to propose changes you've made to a project and discuss those changes with others",
),
subheading: __('Interested parties can even contribute by pushing commits if they want to.'),
alt: __('Merge Requests'),
},
};
</script>
<template>
<div class="row empty-state merge-requests">
<div class="col-12">
<div class="svg-content">
<img :src="imagePath" :alt="$options.strings.alt" />
</div>
</div>
<div class="col-12">
<div class="text-content">
<h4>{{ $options.strings.heading }}</h4>
<p>{{ $options.strings.subheading }}</p>
</div>
</div>
</div>
</template>
<script>
import { sprintf, s__ } from '~/locale';
import { GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import Approvers from './approvers.vue';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
CiIcon,
Approvers,
},
mixins: [timeagoMixin],
props: {
mergeRequest: {
type: Object,
required: true,
},
},
computed: {
hasCiPipeline() {
return Boolean(this.mergeRequest.pipeline_status);
},
pipelineCiStatus() {
const details = this.mergeRequest.pipeline_status;
return { ...details, group: details.group || details.label };
},
pipelineTitle() {
const { tooltip } = this.mergeRequest.pipeline_status;
return sprintf(s__('PipelineStatusTooltip|Pipeline: %{ci_status}'), {
ci_status: tooltip,
});
},
timeAgoString() {
return sprintf(s__('merged %{time_ago}'), {
time_ago: this.timeFormatted(this.mergeRequest.merged_at),
});
},
timeTooltip() {
return this.tooltipTitle(this.mergeRequest.merged_at);
},
},
};
</script>
<template>
<li class="merge-request">
<div class="issuable-info-container">
<div class="issuable-main-info">
<div class="title">
<a :href="mergeRequest.path">
{{ mergeRequest.title }}
</a>
</div>
<span class="gl-text-gray-700">
{{ mergeRequest.issuable_reference }}
</span>
</div>
<div class="issuable-meta">
<ul class="controls">
<li v-if="hasCiPipeline" class="mr-2">
<a :href="pipelineCiStatus.details_path">
<ci-icon
v-gl-tooltip.left="pipelineTitle"
class="d-flex"
:status="pipelineCiStatus"
/>
</a>
</li>
<approvers :approvers="mergeRequest.approved_by_users" />
</ul>
<span class="gl-text-gray-700">
<time v-gl-tooltip.bottom="timeTooltip">{{ timeAgoString }}</time>
</span>
</div>
</div>
</li>
</template>
<script>
import { GlPagination } from '@gitlab/ui';
import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility';
export default {
components: {
GlPagination,
},
props: {
isLastPage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
page: parseInt(getParameterValues('page')[0], 10) || 1,
};
},
computed: {
isOnlyPage() {
return this.isLastPage && this.page === 1;
},
prevPage() {
return this.page > 1 ? this.page - 1 : null;
},
nextPage() {
return !this.isLastPage ? this.page + 1 : null;
},
},
methods: {
generateLink(page) {
return setUrlParams({ page });
},
},
};
</script>
<template>
<gl-pagination
v-if="!isOnlyPage"
v-model="page"
:prev-page="prevPage"
:next-page="nextPage"
:link-gen="generateLink"
align="center"
class="w-100"
/>
</template>
export const PRESENTABLE_APPROVERS_LIMIT = 2;
export default {};
import initComplianceDashboard from 'ee/compliance_dashboard/compliance_dashboard_bundle';
document.addEventListener('DOMContentLoaded', initComplianceDashboard);
...@@ -3,13 +3,4 @@ ...@@ -3,13 +3,4 @@
border-top: 1px solid $border-color; border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
} }
.author-link {
align-items: center;
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
} }
...@@ -7,13 +7,23 @@ class Groups::Security::ComplianceDashboardsController < Groups::ApplicationCont ...@@ -7,13 +7,23 @@ class Groups::Security::ComplianceDashboardsController < Groups::ApplicationCont
before_action :authorize_compliance_dashboard! before_action :authorize_compliance_dashboard!
def show def show
@merge_requests = MergeRequestsComplianceFinder.new(current_user, { group_id: @group.id }) @last_page = paginated_merge_requests.last_page?
.execute @merge_requests = serialize(paginated_merge_requests)
@merge_requests = Kaminari.paginate_array(@merge_requests).page(params[:page])
end end
private private
def paginated_merge_requests
@paginated_merge_requests ||= begin
merge_requests = MergeRequestsComplianceFinder.new(current_user, { group_id: @group.id }).execute
Kaminari.paginate_array(merge_requests).page(params[:page])
end
end
def serialize(merge_requests)
MergeRequestSerializer.new(current_user: current_user).represent(merge_requests, serializer: 'compliance_dashboard')
end
def authorize_compliance_dashboard! def authorize_compliance_dashboard!
render_404 unless group_level_compliance_dashboard_available?(group) render_404 unless group_level_compliance_dashboard_available?(group)
end end
......
# frozen_string_literal: true
module EE
module MergeRequestSerializer
extend ::Gitlab::Utils::Override
override :represent
def represent(merge_request, opts = {}, entity = nil)
entity ||=
case opts[:serializer]
when 'compliance_dashboard'
MergeRequestComplianceEntity
end
super(merge_request, opts, entity)
end
end
end
# frozen_string_literal: true
class MergeRequestComplianceEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :title
expose :merged_at
expose :milestone
expose :path do |merge_request|
merge_request_path(merge_request)
end
expose :issuable_reference do |merge_request|
merge_request.to_reference(merge_request.project.group)
end
expose :approved_by_users, using: API::Entities::UserBasic
expose :pipeline_status, if: -> (*) { can_read_pipeline? }, with: DetailedStatusEntity
private
alias_method :merge_request, :object
def can_read_pipeline?
can?(request.current_user, :read_pipeline, merge_request.head_pipeline)
end
def pipeline_status
merge_request.head_pipeline.detailed_status(request.current_user)
end
end
- presentable_approvers_limit = 2
- approvers_over_presentable_limit = merge_request.approved_by_users.size - presentable_approvers_limit
- project = merge_request.project
%li.issuable-status
%span.gl-text-gray-700
= _('Approved by: ')
- merge_request.approved_by_users.take(presentable_approvers_limit).each do |approver| # rubocop: disable CodeReuse/ActiveRecord
= link_to_member(project, approver, name: true, title: "Approved by :name")
- if approvers_over_presentable_limit.positive?
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter' }, title: _("+%{approvers} more approvers") % { approvers: approvers_over_presentable_limit } }
= "+ #{approvers_over_presentable_limit}"
.row.empty-state.merge-requests
.col-12
.svg-content
= image_tag 'illustrations/merge_requests.svg'
.col-12
.text-content
%h4
= _("Merge requests are a place to propose changes you've made to a project and discuss those changes with others")
%p
= _("Interested parties can even contribute by pushing commits if they want to.")
%li.merge-request{ id: dom_id(merge_request), data: { id: merge_request.id } }
.issuable-info-container
.issuable-main-info
.title
= link_to merge_request.title, merge_request_path(merge_request)
%span.gl-text-gray-700
= issuable_reference(merge_request)
.issuable-meta
%ul.controls
= render 'shared/merge_request_pipeline_status', merge_request: merge_request
- if merge_request.approved_by_users.any?
= render 'approvers', project: merge_request.project, merge_request: merge_request
- else
%li.issuable-status
%span.gl-text-gray-700
= _('No approvers')
%span.gl-text-gray-700
= _('merged %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(merge_request.merged_at, placement: 'bottom', html_class: 'merge_request_updated_ago') }
- if @merge_requests.present?
.compliance-dashboard
%header.my-3
%h4= _("Compliance Dashboard")
%p= _("Here you will find recent merge request activity")
%ul.content-list.issuable-list.issues-list
= render partial: 'merge_request', collection: @merge_requests
= paginate_without_count @merge_requests
- else
= render 'empty_state'
- breadcrumb_title _("Compliance Dashboard") - breadcrumb_title _("Compliance Dashboard")
- page_title _("Compliance Dashboard") - page_title _("Compliance Dashboard")
= render "merge_requests" #js-compliance-dashboard{ data: { merge_requests: @merge_requests.to_json,
is_last_page: @last_page.to_json,
empty_state_svg_path: image_path('illustrations/merge_requests.svg') } }
# frozen_string_literal: true
require 'spec_helper'
describe 'Compliance Dashboard', :js do
let_it_be(:current_user) { create(:user) }
let_it_be(:user) { current_user }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, :public, namespace: group) }
before do
stub_licensed_features(group_level_compliance_dashboard: true)
group.add_owner(user)
sign_in(user)
visit group_security_compliance_dashboard_path(group)
end
context 'when there are no merge requests' do
it 'shows an empty state' do
expect(page).to have_selector('.empty-state')
end
end
context 'when there are merge requests' do
let_it_be(:merge_request) { create(:merge_request, source_project: project, state: :merged) }
before_all do
create(:event, :merged, project: project, target: merge_request, author: user, created_at: 10.minutes.ago)
end
it 'shows merge requests with details' do
expect(page).to have_link(merge_request.title)
expect(page).to have_content('merged 10 minutes ago')
expect(page).to have_content('No approvers')
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MergeRequest component when there are approvers matches snapshot 1`] = `
<li
class="issuable-status d-flex approvers align-items-center"
>
<span
class="gl-text-gray-700"
>
Approved by:
</span>
<gl-link-stub
class="gl-avatar-link d-flex align-items-center ml-2 author-link js-user-link "
data-name="User 0"
data-user-id="0"
href="http://localhost:3000/user-0"
title="User 0"
>
<gl-avatar-stub
alt="avatar"
class="mr-1"
entityid="0"
entityname="User 0"
shape="circle"
size="16"
src="https://0"
/>
<span>
User 0
</span>
</gl-link-stub>
<!---->
</li>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ComplianceDashboard component when there are merge requests matches the snapshot 1`] = `
<div
class="compliance-dashboard"
>
<header
class="my-3"
>
<h4>
Compliance Dashboard
</h4>
<p>
Here you will find recent merge request activity
</p>
</header>
<ul
class="content-list issuable-list issues-list"
>
<div
class="merge-request"
>
Merge request 0
</div>
<div
class="merge-request"
>
Merge request 1
</div>
</ul>
<pagination-stub
class="my-3"
/>
</div>
`;
exports[`ComplianceDashboard component when there are no merge requests matches the snapshot 1`] = `
<empty-state-stub
imagepath="empty.svg"
/>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MergeRequest component when there is a merge request matches the snapshot 1`] = `
<li
class="merge-request"
>
<div
class="issuable-info-container"
>
<div
class="issuable-main-info"
>
<div
class="title"
>
<a
href="/h5bp/html5-boilerplate/-/merge_requests/1"
>
Merge request 1
</a>
</div>
<span
class="gl-text-gray-700"
>
!1
</span>
</div>
<div
class="issuable-meta"
>
<ul
class="controls"
>
<!---->
<approvers-stub
approvers=""
/>
</ul>
<span
class="gl-text-gray-700"
>
<time>
merged 2 days ago
</time>
</span>
</div>
</div>
</li>
`;
import { shallowMount } from '@vue/test-utils';
import { GlAvatarLink } from '@gitlab/ui';
import Approvers from 'ee/compliance_dashboard/components/approvers.vue';
import { PRESENTABLE_APPROVERS_LIMIT } from 'ee/compliance_dashboard/constants';
import { createApprovers } from '../mock_data';
describe('MergeRequest component', () => {
let wrapper;
const findMessage = () => wrapper.find('li > span');
const findCounter = () => wrapper.find('.avatar-counter');
const findAvatarLinks = () => wrapper.findAll(GlAvatarLink);
const createComponent = (approvers = []) => {
return shallowMount(Approvers, {
propsData: {
approvers,
},
stubs: {
GlAvatarLink,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there are no approvers', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('displays the "No approvers" message', () => {
expect(findMessage().text()).toEqual('No approvers');
});
});
describe('when there are approvers', () => {
beforeEach(() => {
wrapper = createComponent(createApprovers(1));
});
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('when the amount of approvers matches the presentable limit', () => {
const approvers = createApprovers(PRESENTABLE_APPROVERS_LIMIT);
beforeEach(() => {
wrapper = createComponent(approvers);
});
it('does not display the additional approvers count', () => {
expect(findCounter().exists()).toEqual(false);
});
it(`displays ${PRESENTABLE_APPROVERS_LIMIT} user avatar links`, () => {
expect(findAvatarLinks().length).toEqual(PRESENTABLE_APPROVERS_LIMIT);
});
});
describe('when the amount of approvers is over the presentable limit', () => {
const additional = 1;
beforeEach(() => {
wrapper = createComponent(createApprovers(PRESENTABLE_APPROVERS_LIMIT + additional));
});
it(`displays only ${PRESENTABLE_APPROVERS_LIMIT} user avatar links`, () => {
expect(findAvatarLinks().length).toEqual(PRESENTABLE_APPROVERS_LIMIT);
});
it('displays additional approvers count', () => {
expect(findCounter().exists()).toEqual(true);
expect(findCounter().text()).toEqual(`+ ${additional}`);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ComplianceDashboard from 'ee/compliance_dashboard/components/dashboard.vue';
import { createMergeRequests } from '../mock_data';
describe('ComplianceDashboard component', () => {
let wrapper;
const findMergeRequests = () => wrapper.findAll('.merge-request');
const createComponent = (props = {}) => {
return shallowMount(ComplianceDashboard, {
propsData: {
mergeRequests: createMergeRequests({ count: 2 }),
isLastPage: false,
emptyStateSvgPath: 'empty.svg',
...props,
},
stubs: {
MergeRequest: {
props: { mergeRequest: Object },
template: `<div class="merge-request">{{ mergeRequest.title }}</div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there are merge requests', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders a list of merge requests', () => {
expect(findMergeRequests().length).toEqual(2);
});
});
describe('when there are no merge requests', () => {
beforeEach(() => {
wrapper = createComponent({ mergeRequests: [] });
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('does not render merge requests', () => {
expect(findMergeRequests().exists()).toEqual(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import EmptyState from 'ee/compliance_dashboard/components/empty_state.vue';
const IMAGE_PATH = 'empty.svg';
describe('EmptyState component', () => {
let wrapper;
const findImage = () => wrapper.find('img');
const findText = () => wrapper.find('.text-content');
const createComponent = (props = {}) => {
return shallowMount(EmptyState, {
propsData: {
imagePath: IMAGE_PATH,
...props,
},
});
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('behaviour', () => {
it('sets the empty SVG path', () => {
expect(findImage().element.getAttribute('src')).toEqual(IMAGE_PATH);
});
it('renders a message', () => {
expect(findText().text()).toEqual(
"Merge requests are a place to propose changes you've made to a project and discuss those changes with others Interested parties can even contribute by pushing commits if they want to.",
);
});
});
});
import { shallowMount } from '@vue/test-utils';
import MergeRequest from 'ee/compliance_dashboard/components/merge_request.vue';
import { createMergeRequest, createPipelineStatus } from '../mock_data';
describe('MergeRequest component', () => {
let wrapper;
const findCiIcon = () => wrapper.find('.ci-icon');
const findCiLink = () => wrapper.find('.controls').find('a');
const findInfo = () => wrapper.find('.issuable-main-info');
const findTime = () => wrapper.find('time');
const createComponent = mergeRequest => {
return shallowMount(MergeRequest, {
propsData: {
mergeRequest,
},
stubs: {
CiIcon: {
props: { status: Object },
template: `<div class="ci-icon">{{ status.group }}</div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there is a merge request', () => {
const mergeRequest = createMergeRequest();
beforeEach(() => {
wrapper = createComponent(mergeRequest);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the title', () => {
expect(
findInfo()
.find('.title')
.text(),
).toEqual(mergeRequest.title);
});
it('renders the issuable reference', () => {
expect(
findInfo()
.find('span')
.text(),
).toEqual(mergeRequest.issuable_reference);
});
it('renders the "merged at" time', () => {
expect(findTime().text()).toEqual('merged 2 days ago');
});
it('does not link to a pipeline', () => {
expect(findCiLink().exists()).toEqual(false);
});
describe('with a pipeline', () => {
const pipeline = createPipelineStatus('success');
beforeEach(() => {
wrapper = createComponent(createMergeRequest({ pipeline }));
});
it('links to the pipeline', () => {
expect(findCiLink().attributes('href')).toEqual(pipeline.details_path);
});
it('renders a CI icon with the pipeline status', () => {
expect(findCiIcon().text()).toEqual(pipeline.group);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import Pagination from 'ee/compliance_dashboard/components/pagination.vue';
describe('MergeRequest component', () => {
let wrapper;
const findGlPagination = () => wrapper.find(GlPagination);
const getLink = query => wrapper.find(query).element.getAttribute('href');
const findPrevPageLink = () => getLink('a.prev-page-item');
const findNextPageLink = () => getLink('a.next-page-item');
const createComponent = (isLastPage = false) => {
return shallowMount(Pagination, {
propsData: {
isLastPage,
},
stubs: {
GlPagination,
},
});
};
beforeEach(() => {
delete window.location;
window.location = new URL('https://localhost');
});
afterEach(() => {
wrapper.destroy();
});
describe('when initialized', () => {
beforeEach(() => {
window.location.search = '?page=2';
wrapper = createComponent();
});
it('should get the page number from the URL', () => {
expect(findGlPagination().props().value).toBe(2);
});
it('should create a link to the previous page', () => {
expect(findPrevPageLink()).toEqual('https://localhost/?page=1');
});
it('should create a link to the next page', () => {
expect(findNextPageLink()).toEqual('https://localhost/?page=3');
});
});
describe('when on last page', () => {
beforeEach(() => {
window.location.search = '?page=2';
wrapper = createComponent(true);
});
it('should not have a nextPage if on the last page', () => {
expect(findGlPagination().props().nextPage).toBe(null);
});
});
describe('when there is only one page', () => {
beforeEach(() => {
window.location.search = '?page=1';
wrapper = createComponent(true);
});
it('should not display if there is only one page of results', () => {
expect(findGlPagination().exists()).toEqual(false);
});
});
});
const twoDaysAgo = () => {
const date = new Date();
date.setDate(date.getDate() - 2);
return date.toISOString();
};
export const createMergeRequest = ({ id = 1, pipeline, approvers } = {}) => {
const mergeRequest = {
id,
approved_by_users: [],
issuable_reference: '!1',
merged_at: twoDaysAgo(),
milestone: null,
path: `/h5bp/html5-boilerplate/-/merge_requests/${id}`,
title: `Merge request ${id}`,
};
if (pipeline) {
mergeRequest.pipeline_status = pipeline;
}
if (approvers) {
mergeRequest.approved_by_users = approvers;
}
return mergeRequest;
};
export const createPipelineStatus = status => ({
details_path: '/h5bp/html5-boilerplate/pipelines/58',
favicon: '',
group: status,
has_details: true,
icon: `status_${status}`,
illustration: null,
label: status,
text: status,
tooltip: status,
});
export const createApprovers = count => {
return Array(count)
.fill()
.map((_, id) => ({
id,
avatar_url: `https://${id}`,
name: `User ${id}`,
state: 'active',
username: `user-${id}`,
web_url: `http://localhost:3000/user-${id}`,
}));
};
export const createMergeRequests = ({ count = 1 } = {}) => {
return Array(count)
.fill()
.map((_, id) => createMergeRequest({ id }));
};
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequestSerializer do
let_it_be(:user) { create(:user) }
let_it_be(:resource) { create(:merge_request, description: "Description") }
let(:json_entity) do
described_class.new(current_user: user)
.represent(resource, serializer: serializer)
.with_indifferent_access
end
context 'compliance_dashboard merge request serialization' do
let(:serializer) { 'compliance_dashboard' }
it 'includes compliance_dashboard attributes' do
expect(json_entity).to include(
:id, :title, :merged_at, :milestone, :path, :issuable_reference, :approved_by_users
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequestComplianceEntity do
include Gitlab::Routing
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let(:merge_request) { create(:merge_request, state: :merged) }
let(:request) { double('request', current_user: user, project: project) }
let(:entity) { described_class.new(merge_request, request: request) }
describe '.as_json' do
subject { entity.as_json }
it 'includes merge request attributes for compliance' do
expect(subject).to include(
:id, :title, :merged_at, :milestone, :path, :issuable_reference, :approved_by_users
)
end
describe 'with a approver' do
let_it_be(:approver) { create(:user) }
let!(:approval) { create :approval, merge_request: merge_request, user: approver }
before do
project.add_developer(approver)
end
it 'includes only set of approver details' do
expect(subject[:approved_by_users].count).to eq(1)
end
it 'includes approver user details' do
expect(subject[:approved_by_users][0][:id]).to eq(approver.id)
end
end
describe 'with a head pipeline' do
let!(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project, head_pipeline_of: merge_request) }
describe 'and the user cannot read the pipeline' do
it 'does not include pipeline status attribute' do
expect(subject).not_to have_key(:pipeline_status)
end
end
describe 'and the user can read the pipeline' do
before do
project.add_developer(user)
end
it 'includes pipeline status attribute' do
expect(subject).to have_key(:pipeline_status)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'groups/security/compliance_dashboards/show.html.haml' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
before do
group.add_owner(user)
stub_licensed_features(group_level_compliance_dashboard: true)
end
context 'when there are no merge requests' do
it 'renders empty state' do
render
expect(rendered).to have_css('div', class: 'empty-state merge-requests')
expect(rendered).not_to have_css('div', class: 'compliance-dashboard')
end
end
context 'when there are merge requests' do
let(:merge_request) { create(:merge_request, source_project: project, state: :merged) }
let(:current_user) { user }
before do
merge_request.metrics.update!(merged_at: 10.minutes.ago)
assign(:merge_requests, Kaminari.paginate_array([merge_request]).page(0))
end
it 'renders merge requests' do
render
expect(rendered).to have_link(merge_request.title)
expect(rendered).not_to have_css('div', class: 'empty-state merge-requests')
end
it 'renders merge requests with time merged tooltip' do
render
expect(rendered).to have_css('time', class: 'js-timeago')
end
context 'with no pipeline' do
it 'renders no pipeline status icon' do
render
expect(rendered).not_to have_css('.ci-status-link')
end
end
context 'with a pipeline' do
context 'and the user is logged in' do
before do
sign_in(current_user)
end
::Ci::Pipeline.bridgeable_statuses.each do |status|
context "and the status is #{status}" do
let!(:pipeline) { create(:ci_empty_pipeline, status: status, project: project, head_pipeline_of: merge_request) }
it "renders the pipeline status icon for #{status}" do
render
expect(rendered).to have_css(".ci-status-link.ci-status-icon-#{status}")
end
end
end
end
context 'and the user is not logged in' do
let(:status) { ::Ci::Pipeline.bridgeable_statuses.first }
let!(:pipeline) { create(:ci_empty_pipeline, status: status, project: project, head_pipeline_of: merge_request) }
it "does not render a pipeline status icon" do
render
expect(rendered).not_to have_css(".ci-status-link.ci-status-icon")
end
end
end
context 'with no approvers' do
it 'renders the message "No approvers"' do
render
expect(rendered).to have_css("li span", text: 'No approvers')
end
end
context 'with a single approvers' do
let(:approver_1) { create(:user) }
let!(:approval_rule) { create :approval_merge_request_rule, merge_request: merge_request, users: [approver_1] }
let!(:approval) { create :approval, merge_request: merge_request, user: approver_1 }
before do
project.add_developer(approver_1)
end
it 'renders a single approver avatar link' do
render
expect(rendered).to have_css('a', class: 'author-link', count: 1)
expect(rendered).to have_link(approver_1.name)
end
end
context 'with more than two approvers' do
let(:approver_1) { create(:user) }
let(:approver_2) { create(:user) }
let(:approver_3) { create(:user) }
let!(:approval_1) { create :approval, merge_request: merge_request, user: approver_1 }
let!(:approval_2) { create :approval, merge_request: merge_request, user: approver_2 }
let!(:approval_3) { create :approval, merge_request: merge_request, user: approver_3 }
before do
project.add_developer(approver_1)
project.add_developer(approver_2)
project.add_developer(approver_3)
end
it 'renders the two latest approvers\'s avatar links' do
render
expect(rendered).to have_css('a', class: 'author-link', count: 2)
end
it 'renders a tooltip for additional approvers' do
render
expect(rendered).to have_css('span', class: 'avatar-counter', text: '+ 1')
end
end
end
end
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