Commit e70e3415 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch...

Merge branch '331263-add-change-made-by-and-peer-reviewed-by-to-the-compliance-dashboard' into 'master'

Add "change made by", "peer reviewed by" and "merged by" to the Compliance Dashboard

See merge request gitlab-org/gitlab!65342
parents 2129455f aa79a72b
......@@ -37,6 +37,10 @@ request:
if the project has one assigned.
- Link to the merge request.
- The merge request's branch path in the format `[source] into [target]`.
- A list of users that committed changes to the merge request.
- A list of users that commented on the merge request.
- A list of users that approved the merge request.
- The user that merged the merge request.
## Use cases
......
<script>
import { GlDrawer } from '@gitlab/ui';
import BranchPath from './drawer_sections/branch_path.vue';
import Committers from './drawer_sections/committers.vue';
import MergedBy from './drawer_sections/merged_by.vue';
import Project from './drawer_sections/project.vue';
import Reference from './drawer_sections/reference.vue';
import Reviewers from './drawer_sections/reviewers.vue';
export default {
components: {
GlDrawer,
BranchPath,
Committers,
GlDrawer,
MergedBy,
Reference,
Reviewers,
Project,
},
props: {
......@@ -68,6 +74,12 @@ export default {
:target-branch="mergeRequest.target_branch"
:target-branch-uri="mergeRequest.target_branch_uri"
/>
<committers :committers="mergeRequest.committers" />
<reviewers
:approvers="mergeRequest.approved_by_users"
:commenters="mergeRequest.participants"
/>
<merged-by :merged-by="mergeRequest.merged_by" />
</template>
</gl-drawer>
</template>
<script>
import { __, n__ } from '~/locale';
import DrawerAvatarsList from '../shared/drawer_avatars_list.vue';
import DrawerSectionHeader from '../shared/drawer_section_header.vue';
export default {
components: {
DrawerAvatarsList,
DrawerSectionHeader,
},
props: {
committers: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
committersHeaderText() {
return n__('%d commit author', '%d commit authors', this.committers.length);
},
},
i18n: {
header: __('Change made by'),
emptyHeader: __('No committers'),
},
};
</script>
<template>
<div>
<drawer-section-header>{{ $options.i18n.header }}</drawer-section-header>
<drawer-avatars-list
:header="committersHeaderText"
:empty-header="$options.i18n.emptyHeader"
:avatars="committers"
/>
</div>
</template>
<script>
import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
import { __ } from '~/locale';
import { DRAWER_AVATAR_SIZE } from '../../constants';
import DrawerSectionHeader from '../shared/drawer_section_header.vue';
import DrawerSectionSubHeader from '../shared/drawer_section_sub_header.vue';
export default {
components: {
DrawerSectionHeader,
DrawerSectionSubHeader,
GlAvatarLabeled,
GlAvatarLink,
},
props: {
mergedBy: {
type: Object,
required: false,
default: () => {},
},
},
computed: {
hasMergedBy() {
return Boolean(this.mergedBy?.name);
},
},
i18n: {
header: __('Merged by'),
emptyHeader: __('Unknown user'),
},
DRAWER_AVATAR_SIZE,
};
</script>
<template>
<div>
<drawer-section-header>{{ $options.i18n.header }}</drawer-section-header>
<gl-avatar-link
v-if="hasMergedBy"
:title="mergedBy.name"
:href="mergedBy.web_url"
class="js-user-link"
:data-user-id="mergedBy.id"
:data-name="mergedBy.name"
>
<gl-avatar-labeled
:size="$options.DRAWER_AVATAR_SIZE"
:entity-name="mergedBy.name"
label=""
:sub-label="mergedBy.name"
:src="mergedBy.avatar_url"
/>
</gl-avatar-link>
<drawer-section-sub-header v-else :is-empty="true">
{{ $options.i18n.emptyHeader }}
</drawer-section-sub-header>
</div>
</template>
......@@ -2,6 +2,7 @@
import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
import ComplianceFrameworkLabel from 'ee/vue_shared/components/compliance_framework_label/compliance_framework_label.vue';
import { __ } from '~/locale';
import { DRAWER_AVATAR_SIZE } from '../../constants';
import DrawerSectionHeader from '../shared/drawer_section_header.vue';
export default {
......@@ -34,6 +35,7 @@ export default {
i18n: {
header: __('Project'),
},
DRAWER_AVATAR_SIZE,
};
</script>
<template>
......@@ -42,7 +44,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<gl-avatar-link :title="name" :href="url">
<gl-avatar-labeled
:size="16"
:size="$options.DRAWER_AVATAR_SIZE"
:entity-name="name"
label=""
:sub-label="name"
......
<script>
import { __, n__ } from '~/locale';
import { DRAWER_AVATAR_SIZE } from '../../constants';
import DrawerAvatarsList from '../shared/drawer_avatars_list.vue';
import DrawerSectionHeader from '../shared/drawer_section_header.vue';
export default {
components: {
DrawerAvatarsList,
DrawerSectionHeader,
},
props: {
approvers: {
type: Array,
required: false,
default: () => [],
},
commenters: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
commentersHeaderText() {
return n__('%d commenter', '%d commenters', this.commenters.length);
},
approversHeaderText() {
return n__('%d approver', '%d approvers', this.approvers.length);
},
hasCommenters() {
return this.commenters.length > 0;
},
hasApprovers() {
return this.approvers.length > 0;
},
},
i18n: {
header: __('Peer review by'),
commentersEmptyHeader: __('No commenters'),
approversEmptyHeader: __('No approvers'),
},
DRAWER_AVATAR_SIZE,
};
</script>
<template>
<div>
<drawer-section-header>{{ $options.i18n.header }}</drawer-section-header>
<drawer-avatars-list
:header="commentersHeaderText"
:empty-header="$options.i18n.commentersEmptyHeader"
:avatars="commenters"
data-testid="commenters-avatar-list"
/>
<drawer-avatars-list
class="gl-mt-4"
:header="approversHeaderText"
:empty-header="$options.i18n.approversEmptyHeader"
:avatars="approvers"
data-testid="approvers-avatar-list"
/>
</div>
</template>
<script>
import { GlAvatar, GlAvatarsInline, GlAvatarLink } from '@gitlab/ui';
import { DRAWER_AVATAR_SIZE, DRAWER_MAXIMUM_AVATARS } from '../../constants';
import DrawerSectionSubHeader from './drawer_section_sub_header.vue';
export default {
components: {
DrawerSectionSubHeader,
GlAvatar,
GlAvatarsInline,
GlAvatarLink,
},
props: {
avatars: {
type: Array,
required: false,
default: () => [],
},
header: {
type: String,
required: false,
default: '',
},
emptyHeader: {
type: String,
required: false,
default: '',
},
},
computed: {
isEmpty() {
return !this.avatars.length;
},
headerText() {
if (this.isEmpty) {
return this.emptyHeader;
}
return this.header;
},
},
DRAWER_AVATAR_SIZE,
DRAWER_MAXIMUM_AVATARS,
};
</script>
<template>
<div>
<drawer-section-sub-header v-if="headerText" :is-empty="isEmpty">
{{ headerText }}
</drawer-section-sub-header>
<gl-avatars-inline
v-if="!isEmpty"
:avatars="avatars"
:max-visible="$options.DRAWER_MAXIMUM_AVATARS"
:avatar-size="$options.DRAWER_AVATAR_SIZE"
class="gl-flex-wrap gl-w-full!"
badge-tooltip-prop="name"
>
<template #avatar="{ avatar }">
<gl-avatar-link
target="blank"
:href="avatar.web_url"
:title="avatar.name"
class="js-user-link"
:data-user-id="avatar.id"
:data-name="avatar.name"
>
<gl-avatar
:src="avatar.avatar_url"
:entity-name="avatar.name"
:size="$options.DRAWER_AVATAR_SIZE"
/>
</gl-avatar-link>
</template>
</gl-avatars-inline>
</div>
</template>
<script>
export default {
props: {
isEmpty: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<p class="gl-text-gray-500" :class="{ 'gl-mb-4': !isEmpty, 'gl-mb-0': isEmpty }">
<slot></slot>
</p>
</template>
......@@ -5,3 +5,7 @@ export const COMPLIANCE_TAB_COOKIE_KEY = 'compliance_dashboard_tabs';
export const INPUT_DEBOUNCE = 500;
export const CUSTODY_REPORT_PARAMETER = 'commit_sha';
export const DRAWER_AVATAR_SIZE = 24;
export const DRAWER_MAXIMUM_AVATARS = 20;
......@@ -34,6 +34,9 @@ class MergeRequestComplianceEntity < Grape::Entity
expose :author, using: API::Entities::UserBasic
expose :approved_by_users, using: API::Entities::UserBasic
expose :committers, using: API::Entities::UserBasic
expose :participants, using: API::Entities::UserBasic
expose :merged_by, using: API::Entities::UserBasic
expose :pipeline_status, if: -> (*) { can_read_pipeline? }, with: DetailedStatusEntity
expose :approval_status
......@@ -71,6 +74,10 @@ class MergeRequestComplianceEntity < Grape::Entity
SUCCESS_APPROVAL_STATUS
end
def merged_by
merge_request.metrics.merged_by
end
def target_branch_uri
project_ref_path(merge_request.project, merge_request.target_branch)
end
......
import { shallowMount } from '@vue/test-utils';
import Committers from 'ee/compliance_dashboard/components/drawer_sections/committers.vue';
import DrawerAvatarsList from 'ee/compliance_dashboard/components/shared/drawer_avatars_list.vue';
import DrawerSectionHeader from 'ee/compliance_dashboard/components/shared/drawer_section_header.vue';
import { createApprovers } from '../../mock_data';
describe('Committers component', () => {
let wrapper;
const findSectionHeader = () => wrapper.findComponent(DrawerSectionHeader);
const findCommitters = () => wrapper.findComponent(DrawerAvatarsList);
const createComponent = (committers) => {
return shallowMount(Committers, {
propsData: { committers },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
const committersList = createApprovers(2);
beforeEach(() => {
wrapper = createComponent(committersList);
});
it('renders the header', () => {
expect(findSectionHeader().text()).toBe('Change made by');
});
it('renders the committers list', () => {
expect(findCommitters().props()).toMatchObject({
avatars: committersList,
});
});
});
describe.each`
number | header | emptyHeader
${1} | ${'1 commit author'} | ${'No committers'}
${3} | ${'3 commit authors'} | ${'No committers'}
`('with $number committers', ({ number, header, emptyHeader }) => {
beforeEach(() => {
wrapper = createComponent(createApprovers(number));
});
it('renders the committers list with the correct header', () => {
expect(findCommitters().props()).toMatchObject({
header,
emptyHeader,
});
});
});
});
import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
import MergedBy from 'ee/compliance_dashboard/components/drawer_sections/merged_by.vue';
import DrawerSectionHeader from 'ee/compliance_dashboard/components/shared/drawer_section_header.vue';
import DrawerSectionSubHeader from 'ee/compliance_dashboard/components/shared/drawer_section_sub_header.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createUser } from '../../mock_data';
describe('MergedBy component', () => {
let wrapper;
const findSectionHeader = () => wrapper.findComponent(DrawerSectionHeader);
const findSubHeader = () => wrapper.findComponent(DrawerSectionSubHeader);
const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const findAvatarLabel = () => wrapper.findComponent(GlAvatarLabeled);
const createComponent = (propsData = {}) => {
return shallowMountExtended(MergedBy, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('without the merged by user', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the header', () => {
expect(findSectionHeader().text()).toBe('Merged by');
});
it('does not render the list', () => {
expect(findAvatarLink().exists()).toBe(false);
expect(findAvatarLabel().exists()).toBe(false);
});
it('does render the empty text', () => {
expect(findSubHeader().text()).toBe('Unknown user');
expect(findSubHeader().props('isEmpty')).toBe(true);
});
});
describe('with the merged by user', () => {
const mergedBy = createUser(1);
beforeEach(() => {
wrapper = createComponent({ mergedBy });
});
it('renders the header', () => {
expect(findSectionHeader().text()).toBe('Merged by');
});
it('renders the list', () => {
expect(findAvatarLink().classes()).toContain('js-user-link');
expect(findAvatarLink().attributes()).toMatchObject({
title: mergedBy.name,
href: mergedBy.web_url,
'data-name': mergedBy.name,
'data-user-id': `${mergedBy.id}`,
});
expect(findAvatarLabel().props()).toMatchObject({
subLabel: mergedBy.name,
label: '',
});
expect(findAvatarLabel().attributes()).toMatchObject({
'entity-name': mergedBy.name,
src: mergedBy.avatar_url,
});
});
it('does not render the empty text', () => {
expect(findSubHeader().exists()).toBe(false);
});
});
});
import Reviewers from 'ee/compliance_dashboard/components/drawer_sections/reviewers.vue';
import DrawerSectionHeader from 'ee/compliance_dashboard/components/shared/drawer_section_header.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createApprovers } from '../../mock_data';
describe('Reviewers component', () => {
let wrapper;
const findSectionHeader = () => wrapper.findComponent(DrawerSectionHeader);
const findCommenters = () => wrapper.findByTestId('commenters-avatar-list');
const findApprovers = () => wrapper.findByTestId('approvers-avatar-list');
const createComponent = (propsData = {}) => {
return shallowMountExtended(Reviewers, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the header', () => {
expect(findSectionHeader().text()).toBe('Peer review by');
});
});
describe('with reviewers', () => {
describe.each`
prop | findMethod | number | header | emptyHeader
${'commenters'} | ${findCommenters} | ${1} | ${'1 commenter'} | ${'No commenters'}
${'commenters'} | ${findCommenters} | ${3} | ${'3 commenters'} | ${'No commenters'}
${'approvers'} | ${findApprovers} | ${1} | ${'1 approver'} | ${'No approvers'}
${'approvers'} | ${findApprovers} | ${2} | ${'2 approvers'} | ${'No approvers'}
`('when $prop has $number users', ({ prop, findMethod, number, header, emptyHeader }) => {
beforeEach(() => {
wrapper = createComponent({ [prop]: createApprovers(number) });
});
it('renders the avatar list with the correct header', () => {
expect(findMethod().props()).toMatchObject({
header,
emptyHeader,
});
});
});
describe('rendering', () => {
const commenters = createApprovers(4);
const approvers = createApprovers(2);
beforeEach(() => {
wrapper = createComponent({ commenters, approvers });
});
it('renders the commenters avatar list', () => {
expect(findCommenters().props()).toMatchObject({
avatars: commenters,
});
});
it('renders the approvers avatar list', () => {
expect(findApprovers().props()).toMatchObject({
avatars: approvers,
});
expect(findApprovers().classes()).toContain('gl-mt-4');
});
});
});
});
import { GlDrawer } from '@gitlab/ui';
import MergeRequestDrawer from 'ee/compliance_dashboard/components/drawer.vue';
import BranchPath from 'ee/compliance_dashboard/components/drawer_sections/branch_path.vue';
import Committers from 'ee/compliance_dashboard/components/drawer_sections/committers.vue';
import MergedBy from 'ee/compliance_dashboard/components/drawer_sections/merged_by.vue';
import Project from 'ee/compliance_dashboard/components/drawer_sections/project.vue';
import Reference from 'ee/compliance_dashboard/components/drawer_sections/reference.vue';
import Reviewers from 'ee/compliance_dashboard/components/drawer_sections/reviewers.vue';
import { complianceFramework } from 'ee_jest/vue_shared/components/compliance_framework_label/mock_data';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMergeRequests } from '../mock_data';
import { createApprovers, createMergeRequests } from '../mock_data';
describe('MergeRequestDrawer component', () => {
let wrapper;
......@@ -13,6 +16,9 @@ describe('MergeRequestDrawer component', () => {
count: 1,
props: {
compliance_management_framework: complianceFramework,
committers: createApprovers(3),
approved_by_users: createApprovers(2),
participants: createApprovers(3),
},
})[0];
......@@ -21,6 +27,9 @@ describe('MergeRequestDrawer component', () => {
const findProject = () => wrapper.findComponent(Project);
const findReference = () => wrapper.findComponent(Reference);
const findBranchPath = () => wrapper.findComponent(BranchPath);
const findCommitters = () => wrapper.findComponent(Committers);
const findReviewers = () => wrapper.findComponent(Reviewers);
const findMergedBy = () => wrapper.findComponent(MergedBy);
const createComponent = (props) => {
return shallowMountExtended(MergeRequestDrawer, {
......@@ -77,6 +86,29 @@ describe('MergeRequestDrawer component', () => {
reference: mergeRequest.reference,
});
});
it('does not have the branch section', () => {
expect(findBranchPath().exists()).toBe(false);
});
it('has the committers section', () => {
expect(findCommitters().props()).toStrictEqual({
committers: mergeRequest.committers,
});
});
it('has the reviewers section', () => {
expect(findReviewers().props()).toStrictEqual({
approvers: mergeRequest.approved_by_users,
commenters: mergeRequest.participants,
});
});
it('has the merged by section', () => {
expect(findMergedBy().props()).toStrictEqual({
mergedBy: mergeRequest.merged_by,
});
});
});
describe('when the branch details are given', () => {
......
import { GlAvatar, GlAvatarLink, GlAvatarsInline } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import DrawerAvatarsList from 'ee/compliance_dashboard/components/shared/drawer_avatars_list.vue';
import DrawerSectionSubHeader from 'ee/compliance_dashboard/components/shared/drawer_section_sub_header.vue';
import { createApprovers } from '../../mock_data';
describe('DrawerAvatarsList component', () => {
let wrapper;
const header = 'Section sub header';
const emptyHeader = 'Empty section sub header';
const avatars = createApprovers(3);
const findHeader = () => wrapper.findComponent(DrawerSectionSubHeader);
const findInlineAvatars = () => wrapper.findComponent(GlAvatarsInline);
const findAvatarLinks = () => wrapper.findAllComponents(GlAvatarLink);
const findAvatars = () => wrapper.findAllComponents(GlAvatar);
const createComponent = (mountFn = shallowMount, propsData = {}) => {
return mountFn(DrawerAvatarsList, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('header', () => {
it('does not render the header if it is not given', () => {
wrapper = createComponent();
expect(findHeader().exists()).toBe(false);
});
it('Renders the header if avatars are given', () => {
wrapper = createComponent(shallowMount, { avatars, header, emptyHeader });
expect(findHeader().text()).toBe(header);
});
it('renders the empty header if no avatars are given', () => {
wrapper = createComponent(shallowMount, { header, emptyHeader });
expect(findHeader().text()).toBe(emptyHeader);
});
});
it('does not render the avatars list if they are not given', () => {
wrapper = createComponent();
expect(findInlineAvatars().exists()).toBe(false);
});
describe('With avatars', () => {
beforeEach(() => {
wrapper = createComponent(mount, { avatars });
});
it('renders the avatars', () => {
expect(findAvatarLinks()).toHaveLength(avatars.length);
expect(findInlineAvatars().props()).toMatchObject({
avatars,
badgeTooltipProp: 'name',
});
});
it('sets the correct attributes to the avatar links', () => {
expect(findAvatarLinks().at(0).classes()).toContain('js-user-link');
expect(findAvatarLinks().at(0).attributes()).toMatchObject({
title: avatars[0].name,
href: avatars[0].web_url,
'data-name': avatars[0].name,
'data-user-id': `${avatars[0].id}`,
});
});
it('sets the correct props to the avatars', () => {
expect(findAvatars().at(0).props()).toMatchObject({
entityName: avatars[0].name,
src: avatars[0].avatar_url,
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import DrawerSectionSubHeader from 'ee/compliance_dashboard/components/shared/drawer_section_sub_header.vue';
describe('DrawerSectionSubHeader component', () => {
let wrapper;
const headerText = 'Section sub header';
const createComponent = (propsData = {}) => {
return shallowMount(DrawerSectionSubHeader, {
propsData,
slots: {
default: headerText,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders the header when not empty', () => {
wrapper = createComponent({ isEmpty: false });
expect(wrapper.text()).toBe(headerText);
expect(wrapper.classes()).toContain('gl-mb-4');
});
it('renders the header when empty', () => {
wrapper = createComponent({ isEmpty: true });
expect(wrapper.text()).toBe(headerText);
expect(wrapper.classes()).toContain('gl-mb-0');
});
});
const createUser = (id) => ({
export const createUser = (id) => ({
id,
avatar_url: `https://${id}`,
name: `User ${id}`,
......@@ -32,6 +32,8 @@ export const createMergeRequest = ({ id = 1, props } = {}) => {
const mergeRequest = {
id,
approved_by_users: [],
committers: [],
participants: [],
issuable_reference: 'project!1',
reference: '!1',
merged_at: mergedAt(),
......@@ -39,6 +41,7 @@ export const createMergeRequest = ({ id = 1, props } = {}) => {
path: `/h5bp/html5-boilerplate/-/merge_requests/${id}`,
title: `Merge request ${id}`,
author: createUser(id),
merged_by: createUser(id),
pipeline_status: createPipelineStatus('success'),
approval_status: 'success',
project: {
......
......@@ -26,6 +26,9 @@ RSpec.describe MergeRequestComplianceEntity do
:reference,
:author,
:approved_by_users,
:committers,
:participants,
:merged_by,
:approval_status,
:target_branch,
:target_branch_uri,
......
......@@ -162,11 +162,21 @@ msgid_plural "%d comments on this commit"
msgstr[0] ""
msgstr[1] ""
msgid "%d commenter"
msgid_plural "%d commenters"
msgstr[0] ""
msgstr[1] ""
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] ""
msgstr[1] ""
msgid "%d commit author"
msgid_plural "%d commit authors"
msgstr[0] ""
msgstr[1] ""
msgid "%d commit behind"
msgid_plural "%d commits behind"
msgstr[0] ""
......@@ -6147,6 +6157,9 @@ msgstr ""
msgid "Change label"
msgstr ""
msgid "Change made by"
msgstr ""
msgid "Change milestone"
msgstr ""
......@@ -20713,6 +20726,9 @@ msgstr ""
msgid "Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes."
msgstr ""
msgid "Merged by"
msgstr ""
msgid "Merged this merge request."
msgstr ""
......@@ -22107,6 +22123,9 @@ msgstr ""
msgid "No application_settings found"
msgstr ""
msgid "No approvers"
msgstr ""
msgid "No assignee"
msgstr ""
......@@ -22131,9 +22150,15 @@ msgstr ""
msgid "No child epics match applied filters"
msgstr ""
msgid "No commenters"
msgstr ""
msgid "No commits present here"
msgstr ""
msgid "No committers"
msgstr ""
msgid "No compliance frameworks are in use."
msgstr ""
......@@ -23776,6 +23801,9 @@ msgstr ""
msgid "Paused runners don't accept new jobs"
msgstr ""
msgid "Peer review by"
msgstr ""
msgid "Pending"
msgstr ""
......@@ -34976,6 +35004,9 @@ msgstr ""
msgid "Unknown response text"
msgstr ""
msgid "Unknown user"
msgstr ""
msgid "Unless otherwise agreed to in writing with GitLab, by clicking \"Upload License\" you agree that your use of GitLab Software is subject to the %{eula_link_start}Terms of Service%{eula_link_end}."
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