Commit 56afab22 authored by Alex Buijs's avatar Alex Buijs Committed by Nailia Iskhakova

Invite members for task experiment

Add an experiment for inviting members to certain
tasks from the in-product marketing emails.

Changelog: added
parent d706c135
<script>
import {
GlAlert,
GlFormGroup,
GlModal,
GlDropdown,
......@@ -16,12 +17,14 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { sanitize } from '~/lib/dompurify';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale';
import {
INVITE_MEMBERS_IN_COMMENT,
GROUP_FILTERS,
USERS_FILTER_ALL,
MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK,
} from '../constants';
import eventHub from '../event_hub';
import {
......@@ -34,6 +37,7 @@ import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
GlAlert,
GlFormGroup,
GlDatepicker,
GlLink,
......@@ -100,6 +104,18 @@ export default {
type: Array,
required: true,
},
tasksToBeDoneOptions: {
type: Array,
required: true,
},
newProjectPath: {
type: String,
required: true,
},
projects: {
type: Array,
required: true,
},
},
data() {
return {
......@@ -110,6 +126,8 @@ export default {
newUsersToInvite: [],
selectedDate: undefined,
selectedAreasOfFocus: [],
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
groupToBeSharedWith: {},
source: 'unknown',
invalidFeedbackMessage: '',
......@@ -156,7 +174,7 @@ export default {
);
},
areasOfFocusEnabled() {
return this.areasOfFocusOptions.length !== 0;
return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0;
},
areasOfFocusForPost() {
if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) {
......@@ -172,12 +190,34 @@ export default {
return this.$options.labels[this.inviteeType].placeHolder;
},
tasksToBeDoneEnabled() {
return getParameterValues('open_modal')[0] === 'invite_members_for_task';
},
showTasksToBeDone() {
return this.tasksToBeDoneEnabled && this.selectedAccessLevel >= 30;
},
showTaskProjects() {
return !this.isProject && this.selectedTasksToBeDone.length;
},
tasksToBeDoneForPost() {
return this.showTasksToBeDone ? this.selectedTasksToBeDone : [];
},
tasksProjectForPost() {
return this.showTasksToBeDone && this.selectedTasksToBeDone.length
? this.selectedTaskProject.id
: '';
},
},
mounted() {
eventHub.$on('openModal', (options) => {
this.openModal(options);
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view);
});
if (this.tasksToBeDoneEnabled) {
this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' });
this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
methods: {
partitionNewUsersToInvite() {
......@@ -219,6 +259,12 @@ export default {
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit);
},
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
this.isLoading = false;
this.selectedAccessLevel = this.defaultAccessLevel;
......@@ -227,10 +273,15 @@ export default {
this.groupToBeSharedWith = {};
this.invalidFeedbackMessage = '';
this.selectedAreasOfFocus = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
changeSelectedTaskProject(project) {
this.selectedTaskProject = project;
},
submitShareWithGroup() {
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
......@@ -263,6 +314,7 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
this.trackInvite();
this.trackinviteMembersForTask();
Promise.all(promises)
.then(this.conditionallyShowToastSuccess)
......@@ -275,6 +327,8 @@ export default {
access_level: this.selectedAccessLevel,
invite_source: this.source,
areas_of_focus: this.areasOfFocusForPost,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
},
addByUserIdPostData(usersToAddById) {
......@@ -284,6 +338,8 @@ export default {
access_level: this.selectedAccessLevel,
invite_source: this.source,
areas_of_focus: this.areasOfFocusForPost,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
},
shareWithGroupPostData(groupToBeSharedWith) {
......@@ -337,6 +393,17 @@ export default {
"InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.",
),
},
tasksToBeDone: {
title: s__(
'InviteMembersModal|Create an issue for your new team member to work on (optional)',
),
noProjects: s__(
'InviteMembersModal|To assign an issue to a new team member, you need a project for the issue. %{linkStart}Create a project to get started.%{linkEnd}',
),
},
tasksProject: {
title: s__('InviteMembersModal|Choose a project for the issues'),
},
},
group: {
modalTitle: s__('InviteMembersModal|Invite a group'),
......@@ -476,6 +543,49 @@ export default {
data-testid="area-of-focus-checks"
/>
</div>
<div v-if="showTasksToBeDone" data-testid="tasks-to-be-done">
<label class="gl-mt-5">
{{ $options.labels.members.tasksToBeDone.title }}
</label>
<template v-if="projects.length">
<gl-form-checkbox-group
v-model="selectedTasksToBeDone"
:options="tasksToBeDoneOptions"
data-testid="tasks"
/>
<template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block">
{{ $options.labels.members.tasksProject.title }}
</label>
<gl-dropdown
class="gl-w-half gl-xs-w-full"
:text="selectedTaskProject.title"
data-testid="project-select"
>
<template v-for="project in projects">
<gl-dropdown-item
:key="project.id"
active-class="is-active"
is-check-item
:is-checked="project.id === selectedTaskProject.id"
@click="changeSelectedTaskProject(project)"
>
{{ project.title }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
</template>
<gl-alert v-else variant="tip" :dismissible="false" data-testid="no-projects-alert">
<gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
<template #link="{ content }">
<gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
</div>
</div>
<template #modal-footer>
......
......@@ -8,6 +8,11 @@ export const MEMBER_AREAS_OF_FOCUS = {
view: 'view',
submit: 'submit',
};
export const INVITE_MEMBERS_FOR_TASK = {
name: 'invite_members_for_task',
view: 'modal_opened_from_email',
submit: 'submit',
};
export const GROUP_FILTERS = {
ALL: 'all',
......
......@@ -24,6 +24,9 @@ export default function initInviteMembersModal() {
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions),
projects: JSON.parse(el.dataset.projects),
newProjectPath: el.dataset.newProjectPath,
noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
......
......@@ -143,6 +143,10 @@ table.content {
line-height: 1.4;
padding: 15px 5px;
text-align: center;
ul.list-style-position-inside {
list-style-position: inside;
}
}
td.mailer-align-left {
......
......@@ -92,6 +92,7 @@ class GroupsController < Groups::ApplicationController
if @group.import_state&.in_progress?
redirect_to group_import_path(@group)
else
publish_invite_members_for_task_experiment
render_show_html
end
end
......@@ -379,6 +380,13 @@ class GroupsController < Groups::ApplicationController
def captcha_required?
captcha_enabled? && !params[:parent_id]
end
def publish_invite_members_for_task_experiment
return unless params[:open_modal] == 'invite_members_for_task'
return unless current_user&.can?(:admin_group_member, @group)
experiment(:invite_members_for_task, namespace: @group).publish_to_client
end
end
GroupsController.prepend_mod_with('GroupsController')
......@@ -16,6 +16,8 @@ module Registrations
result = ::Users::SignupService.new(current_user, update_params).execute
if result[:status] == :success
return redirect_to issues_dashboard_path(assignee_username: current_user.username) if show_tasks_to_be_done?
return redirect_to experiment(:combined_registration, user: current_user).redirect_path(trial_params) if show_signup_onboarding?
members = current_user.members
......@@ -68,6 +70,10 @@ module Registrations
false
end
def show_tasks_to_be_done?
current_user.members.last&.tasks_to_be_done.present?
end
def trial_params
nil
end
......
......@@ -32,7 +32,10 @@ module InviteMembersHelper
dataset = {
id: source.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST
default_access_level: Gitlab::Access::GUEST,
tasks_to_be_done_options: tasks_to_be_done_options.to_json,
projects: projects_for_source(source).to_json,
new_project_path: source.is_a?(Group) ? new_project_path(namespace_id: source.id) : ''
}
experiment(:member_areas_of_focus, user: current_user) do |e|
......@@ -71,4 +74,13 @@ module InviteMembersHelper
def users_filter_data(group)
{}
end
def tasks_to_be_done_options
::Member::TASKS_TO_BE_DONE.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } }
end
def projects_for_source(source)
projects = source.is_a?(Project) ? [source] : source.projects
projects.map { |project| { id: project.id, title: project.title } }
end
end
......@@ -56,6 +56,14 @@ module MembersHelper
end
end
def localized_tasks_to_be_done_choices
{
code: s_('TasksToBeDone|Create/import code into a project (repository)'),
ci: s_('TasksToBeDone|Set up CI/CD pipelines to build, test, deploy, and monitor code'),
issues: s_('TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work')
}.with_indifferent_access.freeze
end
private
def source_text(member)
......
......@@ -317,13 +317,15 @@ class Group < Namespace
owners.include?(user)
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
Members::Groups::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
self,
users,
access_level,
current_user: current_user,
expires_at: expires_at
expires_at: expires_at,
tasks_to_be_done: tasks_to_be_done,
tasks_project_id: tasks_project_id
)
end
......
......@@ -16,12 +16,18 @@ class Member < ApplicationRecord
AVATAR_SIZE = 40
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
TASKS_TO_BE_DONE = {
code: 0,
ci: 1,
issues: 2
}.freeze
attr_accessor :raw_invite_token
belongs_to :created_by, class_name: "User"
belongs_to :user
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :tasks_project, class_name: 'Project'
delegate :name, :username, :email, to: :user, prefix: true
......@@ -377,6 +383,16 @@ class Member < ApplicationRecord
created_by&.name
end
def tasks_to_be_done
Array(self[:tasks_to_be_done]).map { |task| TASKS_TO_BE_DONE.key(task) }
end
def tasks_to_be_done=(tasks)
self[:tasks_to_be_done] = Array(tasks).map do |task|
TASKS_TO_BE_DONE[task.to_sym] || raise(ArgumentError, "#{task} is not a valid value for tasks_to_be_done")
end.uniq
end
private
def send_invite
......@@ -413,6 +429,10 @@ class Member < ApplicationRecord
def after_accept_invite
post_create_hook
run_after_commit_or_now do
TasksToBeDone::CreateWorker.perform_async(tasks_project_id, created_by_id, [user_id.to_i], tasks_to_be_done)
end
end
def after_decline_invite
......
......@@ -41,13 +41,15 @@ class ProjectTeam
member
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,
current_user: current_user,
expires_at: expires_at
expires_at: expires_at,
tasks_to_be_done: tasks_to_be_done,
tasks_project_id: tasks_project_id
)
end
......
......@@ -6,7 +6,7 @@ module Members
included do
class << self
def add_users(source, users, access_level, current_user: nil, expires_at: nil)
def add_users(source, users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
return [] unless users.present?
emails, users, existing_members = parse_users_list(source, users)
......@@ -18,7 +18,9 @@ module Members
access_level,
existing_members: existing_members,
current_user: current_user,
expires_at: expires_at)
expires_at: expires_at,
tasks_to_be_done: tasks_to_be_done,
tasks_project_id: tasks_project_id)
.execute
end
end
......
......@@ -63,10 +63,14 @@ module Members
invites,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
current_user: current_user,
tasks_to_be_done: params[:tasks_to_be_done],
tasks_project_id: params[:tasks_project_id]
)
members.each { |member| process_result(member) }
create_tasks_to_be_done
end
def process_result(member)
......@@ -112,6 +116,14 @@ module Members
end
end
def create_tasks_to_be_done
# Only create task issues for existing users. Tasks for new users are created when they signup.
return if self.instance_of?(Members::InviteService)
return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
TasksToBeDone::CreateWorker.perform_async(params[:tasks_project_id], current_user.id, invites.map(&:to_i), params[:tasks_to_be_done])
end
def areas_of_focus
params[:areas_of_focus] || []
end
......
......@@ -57,7 +57,9 @@ module Members
{
created_by: member.created_by || current_user,
access_level: access_level,
expires_at: args[:expires_at]
expires_at: args[:expires_at],
tasks_to_be_done: args[:tasks_to_be_done],
tasks_project_id: args[:tasks_project_id]
}
end
......
# frozen_string_literal: true
module TasksToBeDone
class BaseService < ::IssuableBaseService
def initialize(project:, current_user:, assignee_ids:)
params = {
assignee_ids: assignee_ids,
title: title,
description: description
}
super(project: project, current_user: current_user, params: params)
end
def execute
if (issue = existing_task_issue)
update_service = Issues::UpdateService.new(project: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] })
update_service.execute(issue)
else
build_service = Issues::BuildService.new(project: project, current_user: current_user, params: params)
create(build_service.execute)
end
end
private
def existing_task_issue
project.issues.opened.where(title: params[:title]).last # rubocop: disable CodeReuse/ActiveRecord
end
end
end
# frozen_string_literal: true
module TasksToBeDone
class CreateCiTaskService < BaseService
protected
def title
'Set up CI/CD'
end
def description
<<~DESCRIPTION
GitLab CI/CD is a tool built into GitLab for software development through the [continuous methodologies](https://docs.gitlab.com/ee/ci/introduction/index.html#introduction-to-cicd-methodologies):
* Continuous Integration (CI)
* Continuous Delivery (CD)
* Continuous Deployment (CD)
Continuous Integration works by pushing small changes to your application’s codebase hosted in a Git repository, and, to every push, run a pipeline of scripts to build, test, and validate the code changes before merging them into the main branch.
Continuous Delivery and Deployment consist of a step further CI, deploying your application to production at every push to the default branch of the repository.
These methodologies allow you to catch bugs and errors early in the development cycle, ensuring that all the code deployed to production complies with the code standards you established for your app.
* :book: [Read the documentation](https://docs.gitlab.com/ee/ci/introduction/index.html)
* :clapper: [Watch a Demo](https://www.youtube.com/watch?v=1iXFbchozdY)
## Next steps
* [ ] To start we recommend reviewing the following documentation:
* [ ] [How GitLab CI/CD works.](https://docs.gitlab.com/ee/ci/introduction/index.html#how-gitlab-cicd-works)
* [ ] [Fundamental pipeline architectures.](https://docs.gitlab.com/ee/ci/pipelines/pipeline_architectures.html)
* [ ] [GitLab CI/CD basic workflow.](https://docs.gitlab.com/ee/ci/introduction/index.html#basic-cicd-workflow)
* [ ] [Step-by-step guide for writing .gitlab-ci.yml for the first time.](https://docs.gitlab.com/ee/user/project/pages/getting_started_part_four.html)
* [ ] When you're ready select **Projects** (in the top navigation bar) > **Your projects** > select the Project you've already created.
* [ ] Select **CI / CD** in the left navigation to start setting up CI / CD in your project.
DESCRIPTION
end
end
end
# frozen_string_literal: true
module TasksToBeDone
class CreateCodeTaskService < BaseService
protected
def title
'Create or import your code into your Project (Repository)'
end
def description
<<~DESCRIPTION
You've already created your Group and Project within GitLab; we'll quickly review this hierarchy below. Once you're within your project you can easily create or import repositories.
**With GitLab Groups, you can:**
* Assemble related projects together.
* Grant members access to several projects at once.
Groups can also be nested in subgroups.
Read more about groups in our [documentation](https://docs.gitlab.com/ee/user/group/).
**Within GitLab Projects, you can**
* Create one or multiple Projects for hosting your codebase (repositories).
* Use it as an issue tracker.
* Collaborate on code.
* Continuously build, test, and deploy your app with built-in GitLab CI/CD.
You can also import an existing repository by providing the Git URL.
* :book: [Read the documentation](https://docs.gitlab.com/ee/user/project/index.html).
## Next steps
Create or import your first repository into the project you created:
* [ ] Click **Projects** in the top navigation bar, then click **Your projects**.
* [ ] Select the Project that you created, then select **Repository**.
* [ ] Once on the Repository page you can select the **+** icon to add or import files.
* [ ] You can review our full documentation on creating [repositories](https://docs.gitlab.com/ee/user/project/repository/) in GitLab.
:tada: All done, you can close this issue!
DESCRIPTION
end
end
end
# frozen_string_literal: true
module TasksToBeDone
class CreateIssuesTaskService < BaseService
protected
def title
'Create/import issues (tickets) to collaborate on ideas and plan work'
end
def description
<<~DESCRIPTION
Issues allow you and your team to discuss proposals before, and during, their implementation. They can be used for a variety of other purposes, customized to your needs and workflow.
Issues are always associated with a specific project. If you have multiple projects in a group, you can view all the issues at the group level. [You can review our full Issue documentation here.](https://docs.gitlab.com/ee/user/project/issues/)
If you have existing issues or equivalent tickets you can import them as long as they are formatted as a CSV file, [the import process is covered here](https://docs.gitlab.com/ee/user/project/issues/csv_import.html).
**Common use cases include:**
* Discussing the implementation of a new idea
* Tracking tasks and work status
* Accepting feature proposals, questions, support requests, or bug reports
* Elaborating on new code implementations
## Next steps
* [ ] Select **Projects** in the top navigation > **Your Projects** > select the Project you've already created.
* [ ] Once you've selected that project, you can select **Issues** in the left navigation, then click **New issue**.
* [ ] Fill in the title and description in the **New issue** page.
* [ ] Click on **Create issue**.
Pro tip: When you're in a group or project you can always utilize the **+** icon in the top navigation (located to the left of the search bar) to quickly create new issues.
That's it! You can close this issue.
DESCRIPTION
end
end
end
......@@ -210,6 +210,12 @@
%td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
%p{ style: "margin: 0 0 50px 0;" }
= @message.feedback_thanks
- if @message.invite_members?
%tr
%td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
= @message.invite_text
%br
= @message.invite_link
%tr{ style: "background-color: #ffffff;" }
%td{ align: "center", style: "padding:75px 20px 25px;" }
= about_link('gitlab_logo.png', 80)
......
......@@ -21,6 +21,10 @@
<%= @message.feedback_thanks %>
<% end %>
<% if @message.invite_members? %>
<%= @message.invite_text %>
<%= @message.invite_link %>
<% end %>
......
......@@ -8,7 +8,11 @@
%td.text-content
%p
= _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type }
- if member.tasks_to_be_done.present?
= s_("InviteEmail|You were assigned the following tasks:")
%ul.list-style-position-inside
- member.tasks_to_be_done.each do |task|
%li= localized_tasks_to_be_done_choices[task]
%p
- leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link }
= _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end }
......@@ -24,6 +24,11 @@
%p
- if member.created_by
= html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to inviter_name, user_url(member.created_by)).html_safe })
- if member.tasks_to_be_done.present?
= s_("InviteEmail|and has assigned you the following tasks:")
%ul.list-style-position-inside
- member.tasks_to_be_done.each do |task|
%li= localized_tasks_to_be_done_choices[task]
- else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions
......
......@@ -2799,6 +2799,15 @@
:weight: 1
:idempotent:
:tags: []
- :name: tasks_to_be_done_create
:worker_name: TasksToBeDone::CreateWorker
:feature_category: :onboarding
:has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
:idempotent: true
:tags: []
- :name: update_external_pull_requests
:worker_name: UpdateExternalPullRequestsWorker
:feature_category: :source_code_management
......
# frozen_string_literal: true
module TasksToBeDone
class CreateWorker
include ApplicationWorker
data_consistency :always
sidekiq_options retry: 3
idempotent!
feature_category :onboarding
urgency :low
worker_resource_boundary :cpu
def perform(project_id, current_user_id, assignee_ids, tasks_to_be_done)
project = Project.find(project_id)
current_user = User.find(current_user_id)
tasks_to_be_done.each do |task|
service_class(task)
.new(project: project, current_user: current_user, assignee_ids: assignee_ids)
.execute
end
end
private
def service_class(task)
"TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize
end
end
end
---
name: invite_members_for_task
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339747
milestone: '14.4'
type: experiment
group: group::activation
default_enabled: false
......@@ -393,6 +393,8 @@
- 1
- - system_hook_push
- 1
- - tasks_to_be_done_create
- 1
- - todos_destroyer
- 1
- - unassign_issuables
......
# frozen_string_literal: true
class AddTasksToBeDoneToMembers < Gitlab::Database::Migration[1.0]
def change
add_column :members, :tasks_to_be_done, :integer, array: true, null: true
add_column :members, :tasks_project_id, :bigint, null: true
end
end
# frozen_string_literal: true
class AddTaskProjectForeignKeyToMembers < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
INDEX_NAME = 'index_members_on_tasks_project_id'
def up
add_concurrent_index :members, :tasks_project_id, name: INDEX_NAME
add_concurrent_foreign_key :members, :projects, column: :tasks_project_id, on_delete: :nullify
end
def down
with_lock_retries do
remove_foreign_key_if_exists :members, column: :tasks_project_id
end
remove_concurrent_index_by_name :members, name: INDEX_NAME
end
end
3744a9e1da77adb40fb7c3f9e3b6116fe0ced1b9e601c3f44fbdc6a31c30c632
\ No newline at end of file
97d8d04db020bc7dbcecf27b69fa82df0667c7c8a0862ca69cdccbb99d7d0c9c
\ No newline at end of file
......@@ -15690,7 +15690,9 @@ CREATE TABLE members (
ldap boolean DEFAULT false NOT NULL,
override boolean DEFAULT false NOT NULL,
state smallint DEFAULT 0,
invite_email_success boolean DEFAULT true NOT NULL
invite_email_success boolean DEFAULT true NOT NULL,
tasks_to_be_done integer[],
tasks_project_id bigint
);
CREATE SEQUENCE members_id_seq
......@@ -25661,6 +25663,8 @@ CREATE INDEX index_members_on_requested_at ON members USING btree (requested_at)
CREATE INDEX index_members_on_source_id_and_source_type ON members USING btree (source_id, source_type);
CREATE INDEX index_members_on_tasks_project_id ON members USING btree (tasks_project_id);
CREATE INDEX index_members_on_user_id_and_access_level_requested_at_is_null ON members USING btree (user_id, access_level) WHERE (requested_at IS NULL);
CREATE INDEX index_members_on_user_id_created_at ON members USING btree (user_id, created_at) WHERE ((ldap = true) AND ((type)::text = 'GroupMember'::text) AND ((source_type)::text = 'Namespace'::text));
......@@ -27572,6 +27576,9 @@ ALTER TABLE ONLY notification_settings
ALTER TABLE ONLY lists
ADD CONSTRAINT fk_0d3f677137 FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE;
ALTER TABLE ONLY members
ADD CONSTRAINT fk_0d981b659b FOREIGN KEY (tasks_project_id) REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE ONLY ci_unit_test_failures
ADD CONSTRAINT fk_0f09856e1f FOREIGN KEY (build_id) REFERENCES ci_builds(id) ON DELETE CASCADE;
......@@ -43,6 +43,8 @@ POST /projects/:id/invitations
| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY |
| `invite_source` | string | no | The source of the invitation that starts the member creation process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327120). |
| `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. |
| `tasks_to_be_done` | array of strings | no | Areas the inviter wants the member to focus upon. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
......@@ -422,6 +422,8 @@ POST /projects/:id/members
| `expires_at` | string | no | A date string in the format `YEAR-MONTH-DAY` |
| `invite_source` | string | no | The source of the invitation that starts the member creation process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327120). |
| `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. |
| `tasks_to_be_done` | array of strings | no | Areas the inviter wants the member to focus upon. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
......@@ -6,7 +6,7 @@ module EE
extend ::Gitlab::Utils::Override
override :add_users
def add_users(users, access_level, current_user: nil, expires_at: nil)
def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
return false if group_member_lock
super
......
......@@ -25,6 +25,8 @@ module API
optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY'
optional :invite_source, type: String, desc: 'Source that triggered the member creation process', default: 'invitations-api'
optional :areas_of_focus, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Areas the inviter wants the member to focus upon'
optional :tasks_to_be_done, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Tasks the inviter wants the member to do'
optional :tasks_project_id, type: Integer, desc: 'The project ID in which to create the task issues'
end
post ":id/invitations" do
params[:source] = find_source(source_type, params[:id])
......
......@@ -95,6 +95,8 @@ module API
optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY'
optional :invite_source, type: String, desc: 'Source that triggered the member creation process', default: 'members-api'
optional :areas_of_focus, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Areas the inviter wants the member to focus upon'
optional :tasks_to_be_done, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Tasks the inviter wants the member to do'
optional :tasks_project_id, type: Integer, desc: 'The project ID in which to create the task issues'
end
post ":id/members" do
......
......@@ -7,6 +7,7 @@ module Gitlab
class Base
include Gitlab::Email::Message::InProductMarketing::Helper
include Gitlab::Routing
include Gitlab::Experiment::Dsl
attr_accessor :format
......@@ -56,6 +57,24 @@ module Gitlab
end
end
def invite_members?
return unless user.can?(:admin_group_member, group)
experiment(:invite_members_for_task, namespace: group) do |e|
e.candidate { true }
e.record!
e.run
end
end
def invite_text
s_('InProductMarketing|Do you have a teammate who would be perfect for this task?')
end
def invite_link
action_link(s_('InProductMarketing|Invite them to help out.'), group_url(group, open_modal: 'invite_members_for_task'))
end
def unsubscribe
parts = Gitlab.com? ? unsubscribe_com : unsubscribe_self_managed(track, series)
......
......@@ -60,6 +60,10 @@ module Gitlab
s_('InProductMarketing|Feedback from users like you really improves our product. Thanks for your help!')
end
def invite_members?
false
end
private
def onboarding_progress
......
......@@ -36,6 +36,15 @@ module Gitlab
"#{text} (#{link})"
end
end
def action_link(text, link)
case format
when :html
ActionController::Base.helpers.link_to text, link, target: '_blank', rel: 'noopener noreferrer'
else
[text, link].join(' >> ')
end
end
end
end
end
......
......@@ -77,6 +77,10 @@ module Gitlab
def progress
super(current: series + 2, total: 4)
end
def invite_members?
false
end
end
end
end
......
......@@ -40,6 +40,10 @@ module Gitlab
def logo_path
'mailers/in_product_marketing/team-0.png'
end
def invite_members?
false
end
end
end
end
......
......@@ -72,6 +72,10 @@ module Gitlab
def progress
super(current: series + 2, total: 4)
end
def invite_members?
false
end
end
end
end
......
......@@ -40,6 +40,10 @@ module Gitlab
def logo_path
'mailers/in_product_marketing/trial-0.png'
end
def invite_members?
false
end
end
end
end
......
......@@ -17533,6 +17533,9 @@ msgstr ""
msgid "InProductMarketing|Do you have a minute?"
msgstr ""
msgid "InProductMarketing|Do you have a teammate who would be perfect for this task?"
msgstr ""
msgid "InProductMarketing|Easy"
msgstr ""
......@@ -17647,6 +17650,9 @@ msgstr ""
msgid "InProductMarketing|Increase Operational Efficiencies"
msgstr ""
msgid "InProductMarketing|Invite them to help out."
msgstr ""
msgid "InProductMarketing|Invite your colleagues and start shipping code faster."
msgstr ""
......@@ -18681,6 +18687,12 @@ msgstr ""
msgid "InviteEmail|You have been invited to join the %{project_or_group_name} %{project_or_group} as a %{role}"
msgstr ""
msgid "InviteEmail|You were assigned the following tasks:"
msgstr ""
msgid "InviteEmail|and has assigned you the following tasks:"
msgstr ""
msgid "InviteMembersBanner|Collaborate with your team"
msgstr ""
......@@ -18699,6 +18711,9 @@ msgstr ""
msgid "InviteMembersModal|Cancel"
msgstr ""
msgid "InviteMembersModal|Choose a project for the issues"
msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
......@@ -18714,6 +18729,9 @@ msgstr ""
msgid "InviteMembersModal|Contribute to the codebase"
msgstr ""
msgid "InviteMembersModal|Create an issue for your new team member to work on (optional)"
msgstr ""
msgid "InviteMembersModal|GitLab member or email address"
msgstr ""
......@@ -18747,6 +18765,9 @@ msgstr ""
msgid "InviteMembersModal|Something went wrong"
msgstr ""
msgid "InviteMembersModal|To assign an issue to a new team member, you need a project for the issue. %{linkStart}Create a project to get started.%{linkEnd}"
msgstr ""
msgid "InviteMembersModal|What would you like new member(s) to focus on? (optional)"
msgstr ""
......@@ -33422,6 +33443,15 @@ msgstr ""
msgid "Task ID: %{elastic_task}"
msgstr ""
msgid "TasksToBeDone|Create/import code into a project (repository)"
msgstr ""
msgid "TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work"
msgstr ""
msgid "TasksToBeDone|Set up CI/CD pipelines to build, test, deploy, and monitor code"
msgstr ""
msgid "Team"
msgstr ""
......
......@@ -82,6 +82,16 @@ RSpec.describe GroupsController, factory_default: :keep do
expect(subject).to redirect_to group_import_path(group)
end
end
context 'publishing the invite_members_for_task experiment' do
it 'publishes the experiment data to the client' do
wrapped_experiment(experiment(:invite_members_for_task)) do |e|
expect(e).to receive(:publish_to_client)
end
get :show, params: { id: group.to_param, open_modal: 'invite_members_for_task' }, format: format
end
end
end
describe 'GET #details' do
......
......@@ -97,6 +97,12 @@ RSpec.describe Registrations::WelcomeController do
expect(subject).to redirect_to(dashboard_projects_path)
end
end
context 'when tasks to be done are assigned' do
let!(:member1) { create(:group_member, user: user, tasks_to_be_done: %w(ci code)) }
it { is_expected.to redirect_to(issues_dashboard_path(assignee_username: user.username)) }
end
end
end
end
......
......@@ -16,16 +16,25 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { INVITE_MEMBERS_IN_COMMENT, MEMBER_AREAS_OF_FOCUS } from '~/invite_members/constants';
import {
INVITE_MEMBERS_IN_COMMENT,
MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK,
} from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
let wrapper;
let mock;
jest.mock('~/experimentation/experiment_tracking');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
getParameterValues: jest.fn(() => []),
}));
const id = '1';
const name = 'test name';
......@@ -40,6 +49,15 @@ const areasOfFocusOptions = [
{ text: 'area1', value: 'area1' },
{ text: 'area2', value: 'area2' },
];
const tasksToBeDoneOptions = [
{ text: 'First task', value: 'first' },
{ text: 'Second task', value: 'second' },
];
const newProjectPath = 'projects/new';
const projects = [
{ text: 'First project', value: '1' },
{ text: 'Second project', value: '2' },
];
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
......@@ -68,6 +86,9 @@ const createComponent = (data = {}, props = {}) => {
areasOfFocusOptions,
defaultAccessLevel,
noSelectionAreasOfFocus,
tasksToBeDoneOptions,
newProjectPath,
projects,
helpLink,
...props,
},
......@@ -131,6 +152,10 @@ describe('InviteMembersModal', () => {
const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
const findTasksToBeDone = () => wrapper.findByTestId('tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('tasks');
const findProjectSelect = () => wrapper.findByTestId('project-select');
const findNoProjectsAlert = () => wrapper.findByTestId('no-projects-alert');
describe('rendering the modal', () => {
beforeEach(() => {
......@@ -191,6 +216,127 @@ describe('InviteMembersModal', () => {
});
});
describe('rendering the tasks to be done', () => {
const setupComponent = (
extraData = {},
props = {},
urlParameter = ['invite_members_for_task'],
) => {
const data = {
selectedAccessLevel: 30,
selectedTasksToBeDone: ['ci', 'code'],
...extraData,
};
getParameterValues.mockImplementation(() => urlParameter);
createComponent(data, props);
};
afterAll(() => {
getParameterValues.mockImplementation(() => []);
});
it('renders the tasks to be done', () => {
setupComponent();
expect(findTasksToBeDone().exists()).toBe(true);
});
describe('when the selected access level is lower than 30', () => {
it('does not render the tasks to be done', () => {
setupComponent({ selectedAccessLevel: 20 });
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => {
it('does not render the tasks to be done', () => {
setupComponent({}, {}, []);
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('rendering the tasks', () => {
it('renders the tasks', () => {
setupComponent();
expect(findTasks().exists()).toBe(true);
});
it('does not render an alert', () => {
setupComponent();
expect(findNoProjectsAlert().exists()).toBe(false);
});
describe('when there are no projects passed in the data', () => {
it('does not render the tasks', () => {
setupComponent({}, { projects: [] });
expect(findTasks().exists()).toBe(false);
});
it('renders an alert with a link to the new projects path', () => {
setupComponent({}, { projects: [] });
expect(findNoProjectsAlert().exists()).toBe(true);
expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe(
newProjectPath,
);
});
});
});
describe('rendering the project dropdown', () => {
it('renders the project select', () => {
setupComponent();
expect(findProjectSelect().exists()).toBe(true);
});
describe('when the modal is shown for a project', () => {
it('does not render the project select', () => {
setupComponent({}, { isProject: true });
expect(findProjectSelect().exists()).toBe(false);
});
});
describe('when no tasks are selected', () => {
it('does not render the project select', () => {
setupComponent({ selectedTasksToBeDone: [] });
expect(findProjectSelect().exists()).toBe(false);
});
});
});
describe('tracking events', () => {
it('tracks the view for invite_members_for_task', () => {
setupComponent();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
INVITE_MEMBERS_FOR_TASK.view,
);
});
it('tracks the submit for invite_members_for_task', () => {
setupComponent();
clickInviteButton();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, {
label: 'selected_tasks_to_be_done',
property: 'ci,code',
});
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
INVITE_MEMBERS_FOR_TASK.submit,
);
});
});
});
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
......@@ -267,6 +413,8 @@ describe('InviteMembersModal', () => {
invite_source: inviteSource,
format: 'json',
areas_of_focus: noSelectionAreasOfFocus,
tasks_to_be_done: [],
tasks_project_id: '',
};
describe('when member is added successfully', () => {
......@@ -448,6 +596,8 @@ describe('InviteMembersModal', () => {
email: 'email@example.com',
invite_source: inviteSource,
areas_of_focus: noSelectionAreasOfFocus,
tasks_to_be_done: [],
tasks_project_id: '',
format: 'json',
};
......@@ -576,6 +726,8 @@ describe('InviteMembersModal', () => {
invite_source: inviteSource,
areas_of_focus: noSelectionAreasOfFocus,
format: 'json',
tasks_to_be_done: [],
tasks_project_id: '',
};
const emailPostData = { ...postData, email: 'email@example.com' };
......
......@@ -59,7 +59,49 @@ RSpec.describe InviteMembersHelper do
no_selection_areas_of_focus: []
}
expect(helper.common_invite_modal_dataset(project)).to match(attributes)
expect(helper.common_invite_modal_dataset(project)).to include(attributes)
end
end
context 'tasks_to_be_done' do
subject(:output) { helper.common_invite_modal_dataset(source) }
context 'for a group' do
let(:source) { create(:group, projects: [project]) }
it 'has the expected attributes', :aggregate_failures do
expect(output[:tasks_to_be_done_options]).to eq(
[
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' },
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq(
new_project_path(namespace_id: source.id)
)
end
end
context 'for a project' do
let(:source) { project }
it 'has the expected attributes', :aggregate_failures do
expect(output[:tasks_to_be_done_options]).to eq(
[
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' },
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq('')
end
end
end
end
......
......@@ -68,4 +68,10 @@ RSpec.describe MembersHelper do
it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.full_name}\" project?" }
it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" }
end
describe '#localized_tasks_to_be_done_choices' do
it 'has a translation for all `TASKS_TO_BE_DONE` keys' do
expect(localized_tasks_to_be_done_choices).to include(*Member::TASKS_TO_BE_DONE.keys)
end
end
end
......@@ -47,22 +47,30 @@ RSpec.describe Emails::InProductMarketing do
end
where(:track, :series) do
:create | 0
:create | 1
:create | 2
:verify | 0
:verify | 1
:verify | 2
:trial | 0
:trial | 1
:trial | 2
:team | 0
:team | 1
:team | 2
:experience | 0
:create | 0
:create | 1
:create | 2
:verify | 0
:verify | 1
:verify | 2
:trial | 0
:trial | 1
:trial | 2
:team | 0
:team | 1
:team | 2
:experience | 0
:team_short | 0
:trial_short | 0
:admin_verify | 0
end
with_them do
before do
stub_experiments(invite_members_for_task: :candidate)
group.add_owner(user)
end
it 'has the correct subject and content' do
message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, user: user, series: series)
......@@ -76,6 +84,14 @@ RSpec.describe Emails::InProductMarketing do
else
is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link))
end
if track =~ /(create|verify)/
is_expected.to have_body_text(message.invite_text)
is_expected.to have_body_text(CGI.unescapeHTML(message.invite_link))
else
is_expected.not_to have_body_text(message.invite_text)
is_expected.not_to have_body_text(CGI.unescapeHTML(message.invite_link))
end
end
end
end
......
......@@ -8,6 +8,7 @@ RSpec.describe Notify do
include EmailSpec::Matchers
include EmailHelpers
include RepoHelpers
include MembersHelper
include_context 'gitlab email notification'
......@@ -761,10 +762,21 @@ RSpec.describe Notify do
is_expected.to have_body_text project_member.human_access
is_expected.to have_body_text 'leave the project'
is_expected.to have_body_text project_url(project, leave: 1)
is_expected.not_to have_body_text 'You were assigned the following tasks:'
end
context 'with tasks to be done present' do
let(:project_member) { create(:project_member, project: project, user: user, tasks_to_be_done: [:ci, :code]) }
it 'contains the assigned tasks to be done' do
is_expected.to have_body_text 'You were assigned the following tasks:'
is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci]
is_expected.to have_body_text localized_tasks_to_be_done_choices[:code]
end
end
end
def invite_to_project(project, inviter:, user: nil)
def invite_to_project(project, inviter:, user: nil, tasks_to_be_done: [])
create(
:project_member,
:developer,
......@@ -772,7 +784,8 @@ RSpec.describe Notify do
invite_token: '1234',
invite_email: 'toto@example.com',
user: user,
created_by: inviter
created_by: inviter,
tasks_to_be_done: tasks_to_be_done
)
end
......@@ -804,6 +817,7 @@ RSpec.describe Notify do
is_expected.to have_content("#{inviter.name} invited you to join the")
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
is_expected.not_to have_body_text 'and has assigned you the following tasks:'
end
end
......@@ -890,6 +904,16 @@ RSpec.describe Notify do
end
end
end
context 'with tasks to be done present', :aggregate_failures do
let(:project_member) { invite_to_project(project, inviter: inviter, tasks_to_be_done: [:ci, :code]) }
it 'contains the assigned tasks to be done' do
is_expected.to have_body_text 'and has assigned you the following tasks:'
is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci]
is_expected.to have_body_text localized_tasks_to_be_done_choices[:code]
end
end
end
describe 'project invitation accepted' do
......@@ -1398,7 +1422,7 @@ RSpec.describe Notify do
end
end
def invite_to_group(group, inviter:, user: nil)
def invite_to_group(group, inviter:, user: nil, tasks_to_be_done: [])
create(
:group_member,
:developer,
......@@ -1406,7 +1430,8 @@ RSpec.describe Notify do
invite_token: '1234',
invite_email: 'toto@example.com',
user: user,
created_by: inviter
created_by: inviter,
tasks_to_be_done: tasks_to_be_done
)
end
......@@ -1431,6 +1456,7 @@ RSpec.describe Notify do
is_expected.to have_body_text group.name
is_expected.to have_body_text group_member.human_access.downcase
is_expected.to have_body_text group_member.invite_token
is_expected.not_to have_body_text 'and has assigned you the following tasks:'
end
end
......@@ -1444,6 +1470,24 @@ RSpec.describe Notify do
is_expected.to have_body_text group_member.invite_token
end
end
context 'with tasks to be done present', :aggregate_failures do
let(:group_member) { invite_to_group(group, inviter: inviter, tasks_to_be_done: [:ci, :code]) }
it 'contains the assigned tasks to be done' do
is_expected.to have_body_text 'and has assigned you the following tasks:'
is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci]
is_expected.to have_body_text localized_tasks_to_be_done_choices[:code]
end
context 'when there is no inviter' do
let(:inviter) { nil }
it 'does not contain the assigned tasks to be done' do
is_expected.not_to have_body_text 'and has assigned you the following tasks:'
end
end
end
end
describe 'group invitation reminders' do
......
......@@ -718,6 +718,21 @@ RSpec.describe Group do
expect(group.group_members.developers.map(&:user)).to include(user)
expect(group.group_members.guests.map(&:user)).not_to include(user)
end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
let!(:project) { create(:project, group: group) }
before do
group.add_users([user], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
end
it 'updates the attributes', :aggregate_failures do
member = group.group_members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(project)
end
end
end
describe '#avatar_type' do
......
......@@ -9,6 +9,7 @@ RSpec.describe Member do
describe 'Associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:tasks_project).class_name('Project') }
end
describe 'Validation' do
......@@ -678,6 +679,18 @@ RSpec.describe Member do
expect(member.invite_token).not_to be_nil
expect_any_instance_of(Member).not_to receive(:after_accept_invite)
end
it 'schedules a TasksToBeDone::CreateWorker task' do
member.tasks_to_be_done = %w(ci code)
member.tasks_project = member.project
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(member.project.id, member.created_by_id, [user.id], [:ci, :code])
.once
member.accept_invite!(user)
end
end
describe '#decline_invite!' do
......@@ -785,6 +798,66 @@ RSpec.describe Member do
end
end
describe '#tasks_to_be_done' do
subject { member.tasks_to_be_done }
let(:member) { Member.new }
before do
member[:tasks_to_be_done] = [0, 1]
end
it 'returns an array of symbols for the corresponding integers' do
expect(subject).to match_array([:ci, :code])
end
end
describe '#tasks_to_be_done=' do
let(:member) { Member.new }
context 'when passing valid values' do
subject { member[:tasks_to_be_done] }
before do
member.tasks_to_be_done = tasks
end
context 'when passing tasks as strings' do
let(:tasks) { %w(ci code) }
it 'sets an array of integers for the corresponding tasks' do
expect(subject).to match_array([0, 1])
end
end
context 'when passing a single task' do
let(:tasks) { :ci }
it 'sets an array of integers for the corresponding tasks' do
expect(subject).to match_array([1])
end
end
context 'when passing a task twice' do
let(:tasks) { %w(ci ci) }
it 'is set only once' do
expect(subject).to match_array([1])
end
end
end
context 'when passing an invalid value' do
it 'raises an error' do
expect do
member.tasks_to_be_done = 'invalid_task'
end.to raise_error(
ArgumentError, 'invalid_task is not a valid value for tasks_to_be_done'
)
end
end
end
describe 'destroying a record', :delete, :sidekiq_inline do
it "refreshes user's authorized projects" do
project = create(:project, :private)
......
......@@ -234,6 +234,19 @@ RSpec.describe ProjectTeam do
expect(project.team.reporter?(user1)).to be(true)
expect(project.team.reporter?(user2)).to be(true)
end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
before do
project.team.add_users([user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
end
it 'updates the attributes', :aggregate_failures do
member = project.project_members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(project)
end
end
end
describe '#add_user' do
......
......@@ -166,6 +166,32 @@ RSpec.describe API::Invitations do
end
end
context 'with tasks_to_be_done and tasks_project_id in the params' do
context 'when there is 1 invitation' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do
post invitations_url(source, maintainer),
params: { email: email, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project.id }
member = source.members.find_by(invite_email: email)
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id)
end
end
context 'when there are multiple invitations' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do
post invitations_url(source, maintainer),
params: { email: [email, email2].join(','), access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project.id }
members = source.members.where(invite_email: [email, email2])
members.each do |member|
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id)
end
end
end
end
context 'with invite_source considerations', :snowplow do
let(:params) { { email: email, access_level: Member::DEVELOPER } }
......
......@@ -406,6 +406,32 @@ RSpec.describe API::Members do
end
end
context 'with tasks_to_be_done and tasks_project_id in the params' do
context 'when there is 1 user to add' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
params: { user_id: stranger.id, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project.id }
member = source.members.find_by(user_id: stranger.id)
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id)
end
end
context 'when there are multiple users to add' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
params: { user_id: [developer.id, stranger.id].join(','), access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project.id }
members = source.members.where(user_id: [developer.id, stranger.id])
members.each do |member|
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id)
end
end
end
end
it "returns 409 if member already exists" do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
params: { user_id: maintainer.id, access_level: Member::MAINTAINER }
......
......@@ -196,4 +196,55 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
end
end
context 'when assigning tasks to be done' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code), tasks_project_id: source.id }
end
it 'creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(source.id, user.id, [member.id], %w(ci code))
.once
.and_call_original
expect { execute_service }.to change { source.issues.count }.by(2)
source.issues.each do |issue|
expect(issue.project).to eq(source)
expect(issue.author).to eq(user)
expect(issue.assignees).to match_array([member])
end
end
context 'when passing many user ids' do
let(:another_user) { create(:user) }
let(:user_ids) { [member.id, another_user.id].join(',') }
it 'still creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(source.id, user.id, [member.id, another_user.id], %w(ci code))
.once
.and_call_original
expect { execute_service }.to change { source.issues.count }.by(2)
source.issues.each do |issue|
expect(issue.project).to eq(source)
expect(issue.author).to eq(user)
expect(issue.assignees).to match_array([member, another_user])
end
end
end
context 'when a `tasks_project_id` is missing' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code) }
end
it 'still creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
end
end
end
end
......@@ -22,6 +22,11 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
it_behaves_like 'records an onboarding progress action', :user_added
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
expect { result }.not_to change { project.issues.count }
end
end
context 'when email belongs to an existing user as a secondary email' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TasksToBeDone::BaseService do
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:assignee_one) { create(:user) }
let_it_be(:assignee_two) { create(:user) }
let_it_be(:assignee_ids) { [assignee_one.id] }
before do
project.add_maintainer(current_user)
project.add_developer(assignee_one)
project.add_developer(assignee_two)
end
subject(:service) do
TasksToBeDone::CreateCiTaskService.new(
project: project,
current_user: current_user,
assignee_ids: assignee_ids
)
end
context 'no existing task issue', :aggregate_failures do
it 'creates an issue' do
params = {
assignee_ids: assignee_ids,
title: 'Set up CI/CD',
description: anything
}
expect(Issues::BuildService)
.to receive(:new)
.with(project: project, current_user: current_user, params: params)
.and_call_original
expect { service.execute }.to change(Issue, :count).by(1)
issue = project.issues.last
expect(issue.author).to eq(current_user)
expect(issue.title).to eq('Set up CI/CD')
expect(issue.assignees).to eq([assignee_one])
end
end
context 'an issue with the same title already exists', :aggregate_failures do
let_it_be(:assignee_ids) { [assignee_two.id] }
it 'assigns the user to the existing issue' do
issue = create(:issue, project: project, author: current_user, title: 'Set up CI/CD', assignees: [assignee_one])
params = { add_assignee_ids: assignee_ids }
expect(Issues::UpdateService)
.to receive(:new)
.with(project: project, current_user: current_user, params: params)
.and_call_original
expect { service.execute }.not_to change(Issue, :count)
expect(issue.reload.assignees).to match_array([assignee_one, assignee_two])
end
end
end
......@@ -299,6 +299,18 @@ RSpec.shared_examples_for "member creation" do
end
end
end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
it 'updates the attributes', :aggregate_failures do
task_project = source.is_a?(Group) ? create(:project, group: source) : source
described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute
member = source.members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(task_project)
end
end
end
end
......@@ -379,5 +391,16 @@ RSpec.shared_examples_for "bulk member creation" do
expect(members).to all(be_persisted)
end
end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
it 'updates the attributes', :aggregate_failures do
task_project = source.is_a?(Group) ? create(:project, group: source) : source
members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id)
member = members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(task_project)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TasksToBeDone::CreateWorker do
subject(:worker) { described_class.new }
describe '.perform' do
let(:project) { create(:project) }
let(:current_user) { create(:user) }
let(:assignee_ids) { [1, 2] }
it 'executes the task services for all available tasks to be done', :aggregate_failures do
expect(TasksToBeDone::CreateCodeTaskService)
.to receive(:new)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
expect(TasksToBeDone::CreateCiTaskService)
.to receive(:new)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
expect(TasksToBeDone::CreateIssuesTaskService)
.to receive(:new)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
worker.perform(project.id, current_user.id, assignee_ids, Member::TASKS_TO_BE_DONE.keys)
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