Commit 58b88f6b authored by Nicolas Dular's avatar Nicolas Dular

First iteration of learn gitlab page experiment

This adds the route, controller and index page for the upcoming
experiment where we will render two different designs of a "Learn
GitLab" page and list the actions that are completed or show a link to
explain how to complete the action.
parent 36e74285
<script>
import { GlLink } from '@gitlab/ui';
import { ACTION_TEXT } from '../constants';
export default {
components: { GlLink },
i18n: {
ACTION_TEXT,
},
props: {
actions: {
required: true,
type: Object,
},
},
};
</script>
<template>
<ul>
<li v-for="(value, action) in actions" :key="action">
<span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span>
<span v-else>
<gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link>
</span>
</li>
</ul>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import { ACTION_TEXT } from '../constants';
export default {
components: { GlLink },
i18n: {
ACTION_TEXT,
},
props: {
actions: {
required: true,
type: Object,
},
},
};
</script>
<template>
<ul>
<li v-for="(value, action) in actions" :key="action">
<span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span>
<span v-else>
<gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link>
</span>
</li>
</ul>
</template>
import { s__ } from '~/locale';
export const ACTION_TEXT = {
gitWrite: s__('LearnGitLab|Create a repository'),
userAdded: s__('LearnGitLab|Invite your colleagues'),
pipelineCreated: s__('LearnGitLab|Set-up CI/CD'),
trialStarted: s__('LearnGitLab|Start a free trial of GitLab Gold'),
codeOwnersEnabled: s__('LearnGitLab|Add code owners'),
requiredMrApprovalsEnabled: s__('LearnGitLab|Enable require merge approvals'),
mergeRequestCreated: s__('LearnGitLab|Submit a merge request (MR)'),
securityScanEnabled: s__('LearnGitLab|Run a Security scan using CI/CD'),
};
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlabA from '../components/learn_gitlab_a.vue';
import LearnGitlabB from '../components/learn_gitlab_b.vue';
function initLearnGitlab() {
const el = document.getElementById('js-learn-gitlab-app');
if (!el) {
return false;
}
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const { learnGitlabA } = gon.experiments;
return new Vue({
el,
render(createElement) {
return createElement(learnGitlabA ? LearnGitlabA : LearnGitlabB, { props: { actions } });
},
});
}
initLearnGitlab();
# frozen_string_literal: true
class Projects::LearnGitlabController < Projects::ApplicationController
before_action :authenticate_user!
before_action :check_experiment_enabled?
feature_category :users
def index
push_frontend_experiment(:learn_gitlab_a, subject: current_user)
push_frontend_experiment(:learn_gitlab_b, subject: current_user)
end
private
def check_experiment_enabled?
return access_denied! unless helpers.learn_gitlab_experiment_enabled?(project)
end
end
# frozen_string_literal: true
module LearnGitlabHelper
def learn_gitlab_experiment_enabled?(project)
return false unless current_user
return false unless experiment_enabled_for_user?
learn_gitlab_onboarding_available?(project)
end
def onboarding_actions_data(project)
attributes = onboarding_progress(project).attributes.symbolize_keys
action_urls.map do |action, url|
[
action,
url: url,
completed: attributes[OnboardingProgress.column_name(action)].present?
]
end.to_h
end
private
ACTION_ISSUE_IDS = {
git_write: 2,
pipeline_created: 4,
merge_request_created: 6,
user_added: 7,
trial_started: 13,
required_mr_approvals_enabled: 15,
code_owners_enabled: 16
}.freeze
ACTION_DOC_URLS = {
security_scan_enabled: 'https://docs.gitlab.com/ee/user/application_security/security_dashboard/#gitlab-security-dashboard-security-center-and-vulnerability-reports'
}.freeze
def action_urls
ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }.merge(ACTION_DOC_URLS)
end
def learn_gitlab_project
@learn_gitlab_project ||= LearnGitlab.new(current_user).project
end
def onboarding_progress(project)
OnboardingProgress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord
end
def experiment_enabled_for_user?
Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_a, subject: current_user) ||
Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_b, subject: current_user)
end
def learn_gitlab_onboarding_available?(project)
OnboardingProgress.onboarding?(project.namespace) &&
LearnGitlab.new(current_user).available?
end
end
......@@ -433,6 +433,8 @@ module ProjectsHelper
nav_tabs += package_nav_tabs(project, current_user)
nav_tabs << :learn_gitlab if learn_gitlab_experiment_enabled?(project)
nav_tabs
end
# rubocop:enable Metrics/CyclomaticComplexity
......
......@@ -47,6 +47,10 @@ class OnboardingProgress < ApplicationRecord
safe_find_or_create_by(namespace: namespace)
end
def onboarding?(namespace)
where(namespace: namespace).any?
end
def register(namespace, action)
return unless root_namespace?(namespace) && ACTIONS.include?(action)
......
......@@ -33,6 +33,13 @@
= link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
%span= _('Releases')
- if project_nav_tab? :learn_gitlab
= nav_link(controller: :learn_gitlab, html_options: { class: 'home' }) do
= link_to project_learn_gitlab_path(@project) do
.nav-icon-container
= sprite_icon('home')
%span.nav-item-name
= _('Learn GitLab')
- if project_nav_tab? :files
= nav_link(controller: sidebar_repository_paths, unless: -> { current_path?('projects/graphs#charts') }) do
......
- breadcrumb_title _("Learn GitLab")
- page_title _("Learn GitLab")
#js-learn-gitlab-app{ data: { actions: onboarding_actions_data(@project).to_json } }
---
name: learn_gitlab_a_experiment_percentage
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53089
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/281022
milestone: '13.9'
type: experiment
group: group::conversion
default_enabled: false
---
name: learn_gitlab_b_experiment_percentage
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53089
rollout_issue_url: https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/306
milestone: '13.9'
type: experiment
group: group::conversion
default_enabled: false
......@@ -87,6 +87,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
get :learn_gitlab, action: :index, controller: 'learn_gitlab'
namespace :ci do
resource :lint, only: [:show, :create]
resource :pipeline_editor, only: [:show], controller: :pipeline_editor, path: 'editor'
......
......@@ -95,6 +95,12 @@ module Gitlab
trial_onboarding_issues: {
tracking_category: 'Growth::Conversion::Experiment::TrialOnboardingIssues'
},
learn_gitlab_a: {
tracking_category: 'Growth::Conversion::Experiment::LearnGitLabA'
},
learn_gitlab_b: {
tracking_category: 'Growth::Activation::Experiment::LearnGitLabB'
},
in_product_marketing_emails: {
tracking_category: 'Growth::Activation::Experiment::InProductMarketingEmails'
}
......
......@@ -17386,6 +17386,30 @@ msgstr ""
msgid "Learn more."
msgstr ""
msgid "LearnGitLab|Add code owners"
msgstr ""
msgid "LearnGitLab|Create a repository"
msgstr ""
msgid "LearnGitLab|Enable require merge approvals"
msgstr ""
msgid "LearnGitLab|Invite your colleagues"
msgstr ""
msgid "LearnGitLab|Run a Security scan using CI/CD"
msgstr ""
msgid "LearnGitLab|Set-up CI/CD"
msgstr ""
msgid "LearnGitLab|Start a free trial of GitLab Gold"
msgstr ""
msgid "LearnGitLab|Submit a merge request (MR)"
msgstr ""
msgid "Leave"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::LearnGitlabController do
describe 'GET #index' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
let(:learn_gitlab_experiment_enabled) { true }
let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
subject { get :index, params: params }
before do
allow(controller.helpers).to receive(:learn_gitlab_experiment_enabled?).and_return(learn_gitlab_experiment_enabled)
end
context 'unauthenticated user' do
it { is_expected.to have_gitlab_http_status(:redirect) }
end
context 'authenticated user' do
before do
sign_in(user)
end
it { is_expected.to render_template(:index) }
it 'pushes experiment to frontend' do
expect(controller).to receive(:push_frontend_experiment).with(:learn_gitlab_a, subject: user)
expect(controller).to receive(:push_frontend_experiment).with(:learn_gitlab_b, subject: user)
subject
end
context 'learn_gitlab experiment not enabled' do
let(:learn_gitlab_experiment_enabled) { false }
it { is_expected.to have_gitlab_http_status(:not_found) }
end
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Learn GitLab Design A should render the loading state 1`] = `
<ul>
<li>
<span>
Create a repository
</span>
</li>
<li>
<span>
Invite your colleagues
</span>
</li>
<li>
<span>
Set-up CI/CD
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Start a free trial of GitLab Gold
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Add code owners
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Enable require merge approvals
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Submit a merge request (MR)
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Run a Security scan using CI/CD
</gl-link-stub>
</span>
</li>
</ul>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Learn GitLab Design B should render the loading state 1`] = `
<ul>
<li>
<span>
Create a repository
</span>
</li>
<li>
<span>
Invite your colleagues
</span>
</li>
<li>
<span>
Set-up CI/CD
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Start a free trial of GitLab Gold
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Add code owners
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Enable require merge approvals
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Submit a merge request (MR)
</gl-link-stub>
</span>
</li>
<li>
<span>
<gl-link-stub
href="http://example.com/"
>
Run a Security scan using CI/CD
</gl-link-stub>
</span>
</li>
</ul>
`;
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue';
const TEST_ACTIONS = {
gitWrite: {
url: 'http://example.com/',
completed: true,
},
userAdded: {
url: 'http://example.com/',
completed: true,
},
pipelineCreated: {
url: 'http://example.com/',
completed: true,
},
trialStarted: {
url: 'http://example.com/',
completed: false,
},
codeOwnersEnabled: {
url: 'http://example.com/',
completed: false,
},
requiredMrApprovalsEnabled: {
url: 'http://example.com/',
completed: false,
},
mergeRequestCreated: {
url: 'http://example.com/',
completed: false,
},
securityScanEnabled: {
url: 'http://example.com/',
completed: false,
},
};
describe('Learn GitLab Design A', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const createWrapper = () => {
wrapper = extendedWrapper(
shallowMount(LearnGitlabA, {
propsData: {
actions: TEST_ACTIONS,
},
}),
);
};
it('should render the loading state', () => {
createWrapper();
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue';
const TEST_ACTIONS = {
gitWrite: {
url: 'http://example.com/',
completed: true,
},
userAdded: {
url: 'http://example.com/',
completed: true,
},
pipelineCreated: {
url: 'http://example.com/',
completed: true,
},
trialStarted: {
url: 'http://example.com/',
completed: false,
},
codeOwnersEnabled: {
url: 'http://example.com/',
completed: false,
},
requiredMrApprovalsEnabled: {
url: 'http://example.com/',
completed: false,
},
mergeRequestCreated: {
url: 'http://example.com/',
completed: false,
},
securityScanEnabled: {
url: 'http://example.com/',
completed: false,
},
};
describe('Learn GitLab Design B', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const createWrapper = () => {
wrapper = extendedWrapper(
shallowMount(LearnGitlabA, {
propsData: {
actions: TEST_ACTIONS,
},
}),
);
};
it('should render the loading state', () => {
createWrapper();
expect(wrapper.element).toMatchSnapshot();
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe LearnGitlabHelper do
include AfterNextHelpers
include Devise::Test::ControllerHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, name: LearnGitlab::PROJECT_NAME, namespace: user.namespace) }
let_it_be(:namespace) { project.namespace }
before do
project.add_developer(user)
allow(helper).to receive(:user).and_return(user)
allow_next_instance_of(LearnGitlab) do |learn_gitlab|
allow(learn_gitlab).to receive(:project).and_return(project)
end
OnboardingProgress.onboard(namespace)
OnboardingProgress.register(namespace, :git_write)
end
describe '.onboarding_actions_data' do
subject(:onboarding_actions_data) { helper.onboarding_actions_data(project) }
it 'has all actions' do
expect(onboarding_actions_data.keys).to contain_exactly(
:git_write,
:pipeline_created,
:merge_request_created,
:user_added,
:trial_started,
:required_mr_approvals_enabled,
:code_owners_enabled,
:security_scan_enabled
)
end
it 'sets correct path and completion status' do
expect(onboarding_actions_data[:git_write]).to eq({
url: project_issue_url(project, LearnGitlabHelper::ACTION_ISSUE_IDS[:git_write]),
completed: true
})
expect(onboarding_actions_data[:pipeline_created]).to eq({
url: project_issue_url(project, LearnGitlabHelper::ACTION_ISSUE_IDS[:pipeline_created]),
completed: false
})
end
end
describe '.learn_gitlab_experiment_enabled?' do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
subject { helper.learn_gitlab_experiment_enabled?(project) }
where(:experiment_a, :experiment_b, :onboarding, :learn_gitlab_available, :result) do
true | false | true | true | true
false | true | true | true | true
false | false | true | true | false
true | true | true | false | false
true | true | false | true | false
end
with_them do
before do
stub_experiment_for_subject(learn_gitlab_a: experiment_a, learn_gitlab_b: experiment_b)
allow(OnboardingProgress).to receive(:onboarding?).with(project.namespace).and_return(onboarding)
allow_next(LearnGitlab, user).to receive(:available?).and_return(learn_gitlab_available)
end
context 'when signed in' do
before do
sign_in(user)
end
it { is_expected.to eq(result) }
end
context 'when not signed in' do
it { is_expected.to eq(false) }
end
end
end
end
......@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe ProjectsHelper do
include ProjectForksHelper
include AfterNextHelpers
let_it_be_with_reload(:project) { create(:project) }
let_it_be_with_refind(:project_with_repo) { create(:project, :repository) }
......@@ -498,6 +499,20 @@ RSpec.describe ProjectsHelper do
it { is_expected.not_to include(:confluence) }
it { is_expected.to include(:wiki) }
end
context 'learn gitlab experiment' do
context 'when it is enabled' do
before do
expect(helper).to receive(:learn_gitlab_experiment_enabled?).with(project).and_return(true)
end
it { is_expected.to include(:learn_gitlab) }
end
context 'when it is not enabled' do
it { is_expected.not_to include(:learn_gitlab) }
end
end
end
describe '#can_view_operations_tab?' do
......
......@@ -114,6 +114,22 @@ RSpec.describe OnboardingProgress do
end
end
describe '.onboarding?' do
subject(:onboarding?) { described_class.onboarding?(namespace) }
context 'when onboarded' do
before do
described_class.onboard(namespace)
end
it { is_expected.to eq true }
end
context 'when not onboarding' do
it { is_expected.to eq false }
end
end
describe '.register' do
subject(:register_action) { described_class.register(namespace, action) }
......
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