Commit 57c75abc authored by Dallas Reedy's avatar Dallas Reedy Committed by Doug Stull

Expose free users to MR approvals feature

- Growth experiment behind the `promote_mr_approvals_in_free` feature
  flag
- Only shown to users who are admins of top-level groups which are
  eligible for trials
- The promo accordion is expanded by default
- The accordion remembers if the user has collapsed it
- Tracking events which help determine the experiment's success
parent 8fe0036d
...@@ -226,6 +226,16 @@ $gl-line-height-42: px-to-rem(42px); ...@@ -226,6 +226,16 @@ $gl-line-height-42: px-to-rem(42px);
max-height: none !important; max-height: none !important;
} }
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655
.gl-max-w-62 {
max-width: $grid-size * 62;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655
.gl-max-w-26 {
max-width: $grid-size * 26;
}
.gl-max-w-50p { .gl-max-w-50p {
max-width: 50%; max-width: 50%;
} }
......
<script>
import { GlAccordion, GlAccordionItem, GlButton, GlLink } from '@gitlab/ui';
export default {
components: {
GlAccordion,
GlAccordionItem,
GlButton,
GlLink,
},
inject: ['learnMorePath', 'promoImageAlt', 'promoImagePath', 'tryNowPath'],
};
</script>
<template>
<div class="gl-mt-2">
<p class="gl-mb-0 gl-text-gray-500">
{{ __('Approvals are optional.') }}
</p>
<gl-accordion :header-level="3">
<gl-accordion-item :title="s__('ApprovalRule|Approval rules')" visible>
<h4 class="gl-font-base gl-line-height-20 gl-mt-5 gl-mb-3">
{{ s__('ApprovalRule|Add required approvers to improve your code review process') }}
</h4>
<div class="gl-display-flex">
<div class="gl-flex-grow-1 gl-max-w-62 gl-mr-5">
<ul class="gl-list-style-position-inside gl-p-0 gl-mb-3">
<li>{{ s__('ApprovalRule|Assign approvers by area of expertise.') }}</li>
<li>{{ s__('ApprovalRule|Increase your organization’s code quality.') }}</li>
<li>{{ s__('ApprovalRule|Reduce the overall time to merge.') }}</li>
<li>
{{
s__(
'ApprovalRule|Let GitLab designate eligible approvers based on the files changed.',
)
}}
</li>
</ul>
<p>
<gl-link :href="learnMorePath" target="_blank">
{{ s__('ApprovalRule|Learn more about merge request approval.') }}
</gl-link>
</p>
<gl-button category="primary" variant="confirm" :href="tryNowPath" target="_blank">{{
s__('ApprovalRule|Try it for free')
}}</gl-button>
</div>
<div class="gl-flex-grow-0 gl-max-w-26 gl-display-none gl-md-display-block">
<img :src="promoImagePath" :alt="promoImageAlt" class="svg gl-w-full" />
</div>
</div>
</gl-accordion-item>
</gl-accordion>
</div>
</template>
import Vue from 'vue';
import FreeTierPromo from './components/mr_edit/free_tier_promo.vue';
export default function mountApprovalPromo(el) {
if (!el) {
return null;
}
const { learnMorePath, promoImageAlt, promoImagePath, tryNowPath } = el.dataset;
return new Vue({
el,
provide: {
learnMorePath,
promoImageAlt,
promoImagePath,
tryNowPath,
},
render(h) {
return h(FreeTierPromo);
},
});
}
import mountApprovals from 'ee/approvals/mount_mr_edit'; import mountApprovals from 'ee/approvals/mount_mr_edit';
import mountApprovalsPromo from 'ee/approvals/mount_mr_promo';
import mountBlockingMergeRequestsInput from 'ee/projects/merge_requests/blocking_mr_input'; import mountBlockingMergeRequestsInput from 'ee/projects/merge_requests/blocking_mr_input';
import initCheckFormState from '~/pages/projects/merge_requests/edit/check_form_state'; import initCheckFormState from '~/pages/projects/merge_requests/edit/check_form_state';
export default () => { export default () => {
const editMrApp = mountApprovals(document.getElementById('js-mr-approvals-input')); const editMrApp = mountApprovals(document.getElementById('js-mr-approvals-input'));
mountApprovalsPromo(document.getElementById('js-mr-approvals-promo'));
mountBlockingMergeRequestsInput(document.getElementById('js-blocking-merge-requests-input')); mountBlockingMergeRequestsInput(document.getElementById('js-blocking-merge-requests-input'));
if (editMrApp) { if (editMrApp) {
......
...@@ -18,3 +18,12 @@ ...@@ -18,3 +18,12 @@
'project_settings_path': presenter.api_project_approval_settings_path } } 'project_settings_path': presenter.api_project_approval_settings_path } }
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner gl-mt-5') = sprite_icon('spinner', size: 24, css_class: 'gl-spinner gl-mt-5')
= render 'projects/merge_requests/code_owner_approval_rules', merge_request: @mr_presenter = render 'projects/merge_requests/code_owner_approval_rules', merge_request: @mr_presenter
- elsif ::Gitlab::CurrentSettings.should_check_namespace_plan?
- top_level_namespace = @target_project.root_ancestor
- if can?(current_user, :admin_group, top_level_namespace)
- experiment(:promote_mr_approvals_in_free, namespace: top_level_namespace, user: current_user, sticky_to: current_user) do |e|
- e.try do
#js-mr-approvals-promo{ data: { try_now_path: new_trial_path,
learn_more_path: help_page_path('user/project/merge_requests/approvals/index'),
promo_image_path: image_path('illustrations/merge_requests.svg'),
promo_image_alt: s_('ApprovalRule|A merge request author collaborating with a merge request approver') } }
---
name: promote_mr_approvals_in_free
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75148
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/346176
milestone: '14.6'
type: experiment
group: group::conversion
default_enabled: false
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'user sees MR approvals promo', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
before do
group.add_owner(user)
stub_application_setting(check_namespace_plan: true)
stub_licensed_features(merge_request_approvers: false)
stub_experiments(promote_mr_approvals_in_free: :candidate)
sign_in(user)
end
describe 'when creating an MR' do
before do
visit project_new_merge_request_path(project,
merge_request: { source_branch: 'fix', target_branch: 'master' }
)
end
it 'shows the promo text' do
expect(page).to have_text('Add required approvers to improve your code review process')
end
end
describe 'when editing an MR' do
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
before do
visit edit_project_merge_request_path(project, merge_request)
end
it 'shows the promo text' do
expect(page).to have_text('Add required approvers to improve your code review process')
end
end
end
import { GlAccordionItem, GlButton, GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { __, s__ } from '~/locale';
import FreeTierPromo from 'ee/approvals/components/mr_edit/free_tier_promo.vue';
describe('PaidFeatureCalloutBadge component', () => {
let wrapper;
const createComponent = (providers = {}) => {
return shallowMountExtended(FreeTierPromo, {
provide: {
learnMorePath: '/learn-more',
promoImageAlt: 'some promo image',
promoImagePath: '/some-image.svg',
tryNowPath: '/try-now',
...providers,
},
});
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('summary text', () => {
it('is rendered correctly', () => {
expect(wrapper.findByText(__('Approvals are optional.')).exists()).toBeTruthy();
});
});
describe('promo gl-accordion item', () => {
let promoItem;
beforeEach(() => {
promoItem = wrapper.findComponent(GlAccordionItem);
});
it('is given the expected title prop', () => {
expect(promoItem.props('title')).toBe(s__('ApprovalRule|Approval rules'));
});
it('starts expanded by default', () => {
expect(promoItem.props('visible')).toBeTruthy();
});
});
describe('promo title', () => {
it('is rendered correctly', () => {
const promoTitle = wrapper.findByRole('heading', {
name: s__('ApprovalRule|Add required approvers to improve your code review process'),
});
expect(promoTitle.exists()).toBeTruthy();
});
});
describe('promo value statements list', () => {
it('contains the expected statements', () => {
const statementItemTexts = wrapper.findAllByRole('listitem').wrappers.map((li) => li.text());
expect(statementItemTexts).toEqual([
s__('ApprovalRule|Assign approvers by area of expertise.'),
s__('ApprovalRule|Increase your organization’s code quality.'),
s__('ApprovalRule|Reduce the overall time to merge.'),
s__('ApprovalRule|Let GitLab designate eligible approvers based on the files changed.'),
]);
});
});
describe('"Learn More" link', () => {
let learnMoreLink;
beforeEach(() => {
learnMoreLink = wrapper.findComponent(GlLink);
});
it('has correct href', () => {
expect(learnMoreLink.attributes('href')).toBe('/learn-more');
});
it('has correct text', () => {
expect(learnMoreLink.text()).toBe(
s__('ApprovalRule|Learn more about merge request approval.'),
);
});
});
describe('"Try Now" button', () => {
let tryNowBtn;
beforeEach(() => {
tryNowBtn = wrapper.findComponent(GlButton);
});
it('has correct href', () => {
expect(tryNowBtn.attributes('href')).toBe('/try-now');
});
it('has correct text', () => {
expect(tryNowBtn.text()).toBe(s__('ApprovalRule|Try it for free'));
});
});
describe('promo image', () => {
it('has correct src', () => {
const promoImage = wrapper.findByAltText('some promo image');
expect(promoImage.attributes('src')).toBe('/some-image.svg');
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'shared/issuable/_approver_suggestion.html.haml' do
let_it_be(:merge_request) { create(:merge_request) }
let_it_be(:user) { merge_request.author }
let_it_be(:presenter) { merge_request.present(current_user: user) }
let(:approvals_available) { true }
before do
allow(view).to receive(:can?).with(user, :update_approvers, merge_request).and_return(true)
allow(view).to receive(:current_user).and_return(user)
allow(presenter).to receive(:approval_feature_available?).and_return(approvals_available)
assign(:target_project, merge_request.target_project)
end
def do_render
render 'shared/issuable/approver_suggestion', issuable: merge_request, presenter: presenter
end
context 'when the approval feature is enabled' do
let(:approvals_available) { true }
before do
assign(:mr_presenter, presenter)
# used inside the projects/merge_requests/_code_owner_approval_rules partial
assign(:project, merge_request.target_project)
end
it 'renders the MR approvals promo' do
do_render
expect(rendered).to have_css('#js-mr-approvals-input')
expect(view).to render_template('projects/merge_requests/_code_owner_approval_rules')
end
end
context 'when the approval feature is not enabled' do
let(:approvals_available) { false }
before do
stub_application_setting(check_namespace_plan: check_namespace_plan)
end
context 'when the check_namespace_plan setting is on' do
let(:check_namespace_plan) { true }
before do
allow(view).to receive(:can?).with(user, :admin_group, anything).and_return(user_can_admin_group)
end
context 'when the user is an owner of the root group' do
let(:user_can_admin_group) { true }
before do
stub_experiments(promote_mr_approvals_in_free: experiment_variant)
end
context 'when the user is in the promote_mr_approvals_in_free experiment' do
let(:experiment_variant) { :candidate }
it 'renders the MR approvals promo' do
do_render
expect(rendered).to have_css('#js-mr-approvals-promo')
end
end
context 'when the user is not in the promote_mr_approvals_in_free experiment' do
let(:experiment_variant) { :control }
it 'renders nothing' do
do_render
expect(rendered).to be_blank
end
end
end
context 'when the user is not an owner of the root group' do
let(:user_can_admin_group) { false }
it 'renders nothing' do
do_render
expect(rendered).to be_blank
end
end
end
context 'when the check_namespace_plan setting is off' do
let(:check_namespace_plan) { false }
it 'renders nothing' do
do_render
expect(rendered).to be_blank
end
end
end
end
...@@ -4353,9 +4353,15 @@ msgstr[1] "" ...@@ -4353,9 +4353,15 @@ msgstr[1] ""
msgid "ApprovalRule|%{firstLabel} +%{numberOfAdditionalLabels} more" msgid "ApprovalRule|%{firstLabel} +%{numberOfAdditionalLabels} more"
msgstr "" msgstr ""
msgid "ApprovalRule|A merge request author collaborating with a merge request approver"
msgstr ""
msgid "ApprovalRule|Add approvers" msgid "ApprovalRule|Add approvers"
msgstr "" msgstr ""
msgid "ApprovalRule|Add required approvers to improve your code review process"
msgstr ""
msgid "ApprovalRule|All scanners" msgid "ApprovalRule|All scanners"
msgstr "" msgstr ""
...@@ -4386,6 +4392,9 @@ msgstr "" ...@@ -4386,6 +4392,9 @@ msgstr ""
msgid "ApprovalRule|Approvers" msgid "ApprovalRule|Approvers"
msgstr "" msgstr ""
msgid "ApprovalRule|Assign approvers by area of expertise."
msgstr ""
msgid "ApprovalRule|Confirmed" msgid "ApprovalRule|Confirmed"
msgstr "" msgstr ""
...@@ -4395,6 +4404,15 @@ msgstr "" ...@@ -4395,6 +4404,15 @@ msgstr ""
msgid "ApprovalRule|Examples: QA, Security." msgid "ApprovalRule|Examples: QA, Security."
msgstr "" msgstr ""
msgid "ApprovalRule|Increase your organization’s code quality."
msgstr ""
msgid "ApprovalRule|Learn more about merge request approval."
msgstr ""
msgid "ApprovalRule|Let GitLab designate eligible approvers based on the files changed."
msgstr ""
msgid "ApprovalRule|Name" msgid "ApprovalRule|Name"
msgstr "" msgstr ""
...@@ -4419,6 +4437,9 @@ msgstr "" ...@@ -4419,6 +4437,9 @@ msgstr ""
msgid "ApprovalRule|Previously detected" msgid "ApprovalRule|Previously detected"
msgstr "" msgstr ""
msgid "ApprovalRule|Reduce the overall time to merge."
msgstr ""
msgid "ApprovalRule|Resolved" msgid "ApprovalRule|Resolved"
msgstr "" msgstr ""
...@@ -4446,6 +4467,9 @@ msgstr "" ...@@ -4446,6 +4467,9 @@ msgstr ""
msgid "ApprovalRule|Target branch" msgid "ApprovalRule|Target branch"
msgstr "" msgstr ""
msgid "ApprovalRule|Try it for free"
msgstr ""
msgid "ApprovalRule|Vulnerabilities allowed" msgid "ApprovalRule|Vulnerabilities allowed"
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