Commit ca2b82b4 authored by Alex Buijs's avatar Alex Buijs

Reviewer feedback implemented

Frontend:
- extract 'minimum_access_level' into constant
- prefix testid's with 'invite-members-modal'
- Use inject/provide instead of props for newProjectPath

Database:
- move 'tasks_to_be_done' to a separate table
- add a unique index for [member_id, project_id]
- search all member_tasks for a user when redirecting

Backend:
- removed unneeded 'with_indifferent_access'
- added model validations
- added idempotency spec
- added test for missing 'tasks_to_be_done'
- search by label instead of title when creating issues

Technical writer/UX:
- textual changes

Dangerbot:
- use 'match_array' instead of 'eq'
parent 56afab22
...@@ -1165,6 +1165,7 @@ Gitlab/NamespacedClass: ...@@ -1165,6 +1165,7 @@ Gitlab/NamespacedClass:
- 'app/models/members/group_member.rb' - 'app/models/members/group_member.rb'
- 'app/models/members/last_group_owner_assigner.rb' - 'app/models/members/last_group_owner_assigner.rb'
- 'app/models/members/project_member.rb' - 'app/models/members/project_member.rb'
- 'app/models/members/member_task.rb'
- 'app/models/members_preloader.rb' - 'app/models/members_preloader.rb'
- 'app/models/merge_request.rb' - 'app/models/merge_request.rb'
- 'app/models/merge_request_assignee.rb' - 'app/models/merge_request_assignee.rb'
......
...@@ -51,6 +51,7 @@ export default { ...@@ -51,6 +51,7 @@ export default {
MembersTokenSelect, MembersTokenSelect,
GroupSelect, GroupSelect,
}, },
inject: ['newProjectPath'],
props: { props: {
id: { id: {
type: String, type: String,
...@@ -108,10 +109,6 @@ export default { ...@@ -108,10 +109,6 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
newProjectPath: {
type: String,
required: true,
},
projects: { projects: {
type: Array, type: Array,
required: true, required: true,
...@@ -191,10 +188,16 @@ export default { ...@@ -191,10 +188,16 @@ export default {
return this.$options.labels[this.inviteeType].placeHolder; return this.$options.labels[this.inviteeType].placeHolder;
}, },
tasksToBeDoneEnabled() { tasksToBeDoneEnabled() {
return getParameterValues('open_modal')[0] === 'invite_members_for_task'; return (
getParameterValues('open_modal')[0] === 'invite_members_for_task' &&
this.tasksToBeDoneOptions.length
);
}, },
showTasksToBeDone() { showTasksToBeDone() {
return this.tasksToBeDoneEnabled && this.selectedAccessLevel >= 30; return (
this.tasksToBeDoneEnabled &&
this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level
);
}, },
showTaskProjects() { showTaskProjects() {
return !this.isProject && this.selectedTasksToBeDone.length; return !this.isProject && this.selectedTasksToBeDone.length;
...@@ -395,10 +398,10 @@ export default { ...@@ -395,10 +398,10 @@ export default {
}, },
tasksToBeDone: { tasksToBeDone: {
title: s__( title: s__(
'InviteMembersModal|Create an issue for your new team member to work on (optional)', 'InviteMembersModal|Create issues for your new team member to work on (optional)',
), ),
noProjects: s__( 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}', 'InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}',
), ),
}, },
tasksProject: { tasksProject: {
...@@ -543,7 +546,7 @@ export default { ...@@ -543,7 +546,7 @@ export default {
data-testid="area-of-focus-checks" data-testid="area-of-focus-checks"
/> />
</div> </div>
<div v-if="showTasksToBeDone" data-testid="tasks-to-be-done"> <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5"> <label class="gl-mt-5">
{{ $options.labels.members.tasksToBeDone.title }} {{ $options.labels.members.tasksToBeDone.title }}
</label> </label>
...@@ -551,7 +554,7 @@ export default { ...@@ -551,7 +554,7 @@ export default {
<gl-form-checkbox-group <gl-form-checkbox-group
v-model="selectedTasksToBeDone" v-model="selectedTasksToBeDone"
:options="tasksToBeDoneOptions" :options="tasksToBeDoneOptions"
data-testid="tasks" data-testid="invite-members-modal-tasks"
/> />
<template v-if="showTaskProjects"> <template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block"> <label class="gl-mt-5 gl-display-block">
...@@ -560,7 +563,7 @@ export default { ...@@ -560,7 +563,7 @@ export default {
<gl-dropdown <gl-dropdown
class="gl-w-half gl-xs-w-full" class="gl-w-half gl-xs-w-full"
:text="selectedTaskProject.title" :text="selectedTaskProject.title"
data-testid="project-select" data-testid="invite-members-modal-project-select"
> >
<template v-for="project in projects"> <template v-for="project in projects">
<gl-dropdown-item <gl-dropdown-item
...@@ -576,7 +579,12 @@ export default { ...@@ -576,7 +579,12 @@ export default {
</gl-dropdown> </gl-dropdown>
</template> </template>
</template> </template>
<gl-alert v-else variant="tip" :dismissible="false" data-testid="no-projects-alert"> <gl-alert
v-else-if="tasksToBeDoneEnabled"
variant="tip"
:dismissible="false"
data-testid="invite-members-modal-no-projects-alert"
>
<gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects"> <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
<template #link="{ content }"> <template #link="{ content }">
<gl-link :href="newProjectPath" target="_blank" class="gl-label-link"> <gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
......
...@@ -9,6 +9,7 @@ export const MEMBER_AREAS_OF_FOCUS = { ...@@ -9,6 +9,7 @@ export const MEMBER_AREAS_OF_FOCUS = {
submit: 'submit', submit: 'submit',
}; };
export const INVITE_MEMBERS_FOR_TASK = { export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task', name: 'invite_members_for_task',
view: 'modal_opened_from_email', view: 'modal_opened_from_email',
submit: 'submit', submit: 'submit',
......
...@@ -14,6 +14,9 @@ export default function initInviteMembersModal() { ...@@ -14,6 +14,9 @@ export default function initInviteMembersModal() {
return new Vue({ return new Vue({
el, el,
provide: {
newProjectPath: el.dataset.newProjectPath,
},
render: (createElement) => render: (createElement) =>
createElement(InviteMembersModal, { createElement(InviteMembersModal, {
props: { props: {
...@@ -24,9 +27,8 @@ export default function initInviteMembersModal() { ...@@ -24,9 +27,8 @@ export default function initInviteMembersModal() {
groupSelectFilter: el.dataset.groupsFilter, groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10), groupSelectParentId: parseInt(el.dataset.parentId, 10),
areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects), projects: JSON.parse(el.dataset.projects || '[]'),
newProjectPath: el.dataset.newProjectPath,
noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter, usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10), filterId: parseInt(el.dataset.filterId, 10),
......
...@@ -71,7 +71,9 @@ module Registrations ...@@ -71,7 +71,9 @@ module Registrations
end end
def show_tasks_to_be_done? def show_tasks_to_be_done?
current_user.members.last&.tasks_to_be_done.present? return unless experiment(:invite_members_for_task).enabled?
MemberTask.for_members(current_user.members).exists?
end end
def trial_params def trial_params
......
...@@ -32,10 +32,7 @@ module InviteMembersHelper ...@@ -32,10 +32,7 @@ module InviteMembersHelper
dataset = { dataset = {
id: source.id, id: source.id,
name: source.name, 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| experiment(:member_areas_of_focus, user: current_user) do |e|
...@@ -45,6 +42,14 @@ module InviteMembersHelper ...@@ -45,6 +42,14 @@ module InviteMembersHelper
e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) } e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) }
end end
if show_invite_members_for_task?
dataset.merge!(
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) : ''
)
end
dataset dataset
end end
...@@ -75,8 +80,14 @@ module InviteMembersHelper ...@@ -75,8 +80,14 @@ module InviteMembersHelper
{} {}
end end
def show_invite_members_for_task?
return unless current_user && experiment(:invite_members_for_task).enabled?
params[:open_modal] == 'invite_members_for_task'
end
def tasks_to_be_done_options def tasks_to_be_done_options
::Member::TASKS_TO_BE_DONE.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } } ::MemberTask::TASKS.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } }
end end
def projects_for_source(source) def projects_for_source(source)
......
...@@ -61,7 +61,7 @@ module MembersHelper ...@@ -61,7 +61,7 @@ module MembersHelper
code: s_('TasksToBeDone|Create/import code into a project (repository)'), 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'), 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') issues: s_('TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work')
}.with_indifferent_access.freeze }.freeze
end end
private private
......
...@@ -13,23 +13,20 @@ class Member < ApplicationRecord ...@@ -13,23 +13,20 @@ class Member < ApplicationRecord
include FromUnion include FromUnion
include UpdateHighestRole include UpdateHighestRole
include RestrictedSignup include RestrictedSignup
include Gitlab::Experiment::Dsl
AVATAR_SIZE = 40 AVATAR_SIZE = 40
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
TASKS_TO_BE_DONE = {
code: 0,
ci: 1,
issues: 2
}.freeze
attr_accessor :raw_invite_token attr_accessor :raw_invite_token
belongs_to :created_by, class_name: "User" belongs_to :created_by, class_name: "User"
belongs_to :user belongs_to :user
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :tasks_project, class_name: 'Project' has_one :member_task
delegate :name, :username, :email, to: :user, prefix: true delegate :name, :username, :email, to: :user, prefix: true
delegate :tasks_to_be_done, to: :member_task, allow_nil: true
validates :expires_at, allow_blank: true, future_date: true validates :expires_at, allow_blank: true, future_date: true
validates :user, presence: true, unless: :invite? validates :user, presence: true, unless: :invite?
...@@ -383,16 +380,6 @@ class Member < ApplicationRecord ...@@ -383,16 +380,6 @@ class Member < ApplicationRecord
created_by&.name created_by&.name
end 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 private
def send_invite def send_invite
...@@ -430,8 +417,12 @@ class Member < ApplicationRecord ...@@ -430,8 +417,12 @@ class Member < ApplicationRecord
def after_accept_invite def after_accept_invite
post_create_hook post_create_hook
run_after_commit_or_now do if experiment(:invite_members_for_task).enabled?
TasksToBeDone::CreateWorker.perform_async(tasks_project_id, created_by_id, [user_id.to_i], tasks_to_be_done) run_after_commit_or_now do
if member_task
TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i])
end
end
end end
end end
......
# frozen_string_literal: true
class MemberTask < ApplicationRecord
TASKS = {
code: 0,
ci: 1,
issues: 2
}.freeze
belongs_to :member
belongs_to :project
validates :member, :project, presence: true
validates :tasks, inclusion: { in: TASKS.values }
validate :tasks_uniqueness
validate :project_in_member_source
scope :for_members, -> (members) { joins(:member).where(member: members) }
def tasks_to_be_done
Array(self[:tasks]).map { |task| TASKS.key(task) }
end
def tasks_to_be_done=(tasks)
self[:tasks] = Array(tasks).map do |task|
TASKS[task.to_sym]
end.uniq
end
private
def tasks_uniqueness
errors.add(:tasks, 'are not unique') unless Array(tasks).length == Array(tasks).uniq.length
end
def project_in_member_source
if member.is_a?(GroupMember)
errors.add(:project, _('is not in the member group')) unless project.namespace == member.source
elsif member.is_a?(ProjectMember)
errors.add(:project, _('is not the member project')) unless project == member.source
end
end
end
...@@ -117,11 +117,16 @@ module Members ...@@ -117,11 +117,16 @@ module Members
end end
def create_tasks_to_be_done def create_tasks_to_be_done
# Only create task issues for existing users. Tasks for new users are created when they signup. return unless experiment(:invite_members_for_task).enabled?
return if self.instance_of?(Members::InviteService)
return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank? 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]) valid_members = members.select { |member| member.valid? && member.member_task.valid? }
return unless valid_members.present?
# We can take the first `member_task` here, since all tasks will have the same attributes needed
# for the `TasksToBeDone::CreateWorker`, ie. `project` and `tasks_to_be_done`.
member_task = valid_members[0].member_task
TasksToBeDone::CreateWorker.perform_async(member_task.id, current_user.id, valid_members.map(&:user_id))
end end
def areas_of_focus def areas_of_focus
......
...@@ -4,6 +4,8 @@ module Members ...@@ -4,6 +4,8 @@ module Members
# This class serves as more of an app-wide way we add/create members # This class serves as more of an app-wide way we add/create members
# All roads to add members should take this path. # All roads to add members should take this path.
class CreatorService class CreatorService
include Gitlab::Experiment::Dsl
class << self class << self
def parsed_access_level(access_level) def parsed_access_level(access_level)
access_levels.fetch(access_level) { access_level.to_i } access_levels.fetch(access_level) { access_level.to_i }
...@@ -24,6 +26,7 @@ module Members ...@@ -24,6 +26,7 @@ module Members
def execute def execute
find_or_build_member find_or_build_member
update_member update_member
create_member_task
member member
end end
...@@ -57,9 +60,22 @@ module Members ...@@ -57,9 +60,22 @@ module Members
{ {
created_by: member.created_by || current_user, created_by: member.created_by || current_user,
access_level: access_level, access_level: access_level,
expires_at: args[:expires_at], expires_at: args[:expires_at]
}
end
def create_member_task
return unless experiment(:invite_members_for_task).enabled?
return unless member.persisted?
return if member_task_attributes.value?(nil)
member.create_member_task(member_task_attributes)
end
def member_task_attributes
{
tasks_to_be_done: args[:tasks_to_be_done], tasks_to_be_done: args[:tasks_to_be_done],
tasks_project_id: args[:tasks_project_id] project_id: args[:tasks_project_id]
} }
end end
......
...@@ -39,6 +39,11 @@ module Members ...@@ -39,6 +39,11 @@ module Members
errors[invite_email(member)] = member.errors.full_messages.to_sentence errors[invite_email(member)] = member.errors.full_messages.to_sentence
end end
override :create_tasks_to_be_done
def create_tasks_to_be_done
# Only create task issues for existing users. Tasks for new users are created when they signup.
end
def invite_email(member) def invite_email(member)
member.invite_email || member.user.email member.invite_email || member.user.email
end end
......
...@@ -2,11 +2,14 @@ ...@@ -2,11 +2,14 @@
module TasksToBeDone module TasksToBeDone
class BaseService < ::IssuableBaseService class BaseService < ::IssuableBaseService
def initialize(project:, current_user:, assignee_ids:) LABEL_PREFIX = 'tasks to be done'
def initialize(project:, current_user:, assignee_ids: [])
params = { params = {
assignee_ids: assignee_ids, assignee_ids: assignee_ids,
title: title, title: title,
description: description description: description,
add_labels: label_name
} }
super(project: project, current_user: current_user, params: params) super(project: project, current_user: current_user, params: params)
end end
...@@ -24,7 +27,29 @@ module TasksToBeDone ...@@ -24,7 +27,29 @@ module TasksToBeDone
private private
def existing_task_issue def existing_task_issue
project.issues.opened.where(title: params[:title]).last # rubocop: disable CodeReuse/ActiveRecord IssuesFinder.new(
current_user,
project_id: project.id,
state: 'opened',
non_archived: true,
label_name: label_name
).execute.last
end
def title
raise NotImplementedError
end
def description
raise NotImplementedError
end
def label_suffix
raise NotImplementedError
end
def label_name
"#{LABEL_PREFIX}:#{label_suffix}"
end end
end end
end end
...@@ -36,5 +36,9 @@ module TasksToBeDone ...@@ -36,5 +36,9 @@ module TasksToBeDone
* [ ] Select **CI / CD** in the left navigation to start setting up CI / CD in your project. * [ ] Select **CI / CD** in the left navigation to start setting up CI / CD in your project.
DESCRIPTION DESCRIPTION
end end
def label_suffix
'ci'
end
end end
end end
...@@ -14,6 +14,7 @@ module TasksToBeDone ...@@ -14,6 +14,7 @@ module TasksToBeDone
**With GitLab Groups, you can:** **With GitLab Groups, you can:**
* Create one or multiple Projects for hosting your codebase (repositories).
* Assemble related projects together. * Assemble related projects together.
* Grant members access to several projects at once. * Grant members access to several projects at once.
...@@ -23,7 +24,6 @@ module TasksToBeDone ...@@ -23,7 +24,6 @@ module TasksToBeDone
**Within GitLab Projects, you can** **Within GitLab Projects, you can**
* Create one or multiple Projects for hosting your codebase (repositories).
* Use it as an issue tracker. * Use it as an issue tracker.
* Collaborate on code. * Collaborate on code.
* Continuously build, test, and deploy your app with built-in GitLab CI/CD. * Continuously build, test, and deploy your app with built-in GitLab CI/CD.
...@@ -44,5 +44,9 @@ module TasksToBeDone ...@@ -44,5 +44,9 @@ module TasksToBeDone
:tada: All done, you can close this issue! :tada: All done, you can close this issue!
DESCRIPTION DESCRIPTION
end end
def label_suffix
'code'
end
end end
end end
...@@ -35,5 +35,9 @@ module TasksToBeDone ...@@ -35,5 +35,9 @@ module TasksToBeDone
That's it! You can close this issue. That's it! You can close this issue.
DESCRIPTION DESCRIPTION
end end
def label_suffix
'issues'
end
end end
end end
...@@ -5,19 +5,17 @@ module TasksToBeDone ...@@ -5,19 +5,17 @@ module TasksToBeDone
include ApplicationWorker include ApplicationWorker
data_consistency :always data_consistency :always
sidekiq_options retry: 3
idempotent! idempotent!
feature_category :onboarding feature_category :onboarding
urgency :low urgency :low
worker_resource_boundary :cpu worker_resource_boundary :cpu
def perform(project_id, current_user_id, assignee_ids, tasks_to_be_done) def perform(member_task_id, current_user_id, assignee_ids = [])
project = Project.find(project_id) member_task = MemberTask.find(member_task_id)
current_user = User.find(current_user_id) current_user = User.find(current_user_id)
project = member_task.project
tasks_to_be_done.each do |task| member_task.tasks_to_be_done.each do |task|
service_class(task) service_class(task)
.new(project: project, current_user: current_user, assignee_ids: assignee_ids) .new(project: project, current_user: current_user, assignee_ids: assignee_ids)
.execute .execute
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
name: invite_members_for_task name: invite_members_for_task
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339747 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339747
milestone: '14.4' milestone: '14.5'
type: experiment type: experiment
group: group::activation group: group::activation
default_enabled: false default_enabled: false
# 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
# frozen_string_literal: true
class CreateMemberTasks < Gitlab::Database::Migration[1.0]
def change
create_table :member_tasks do |t|
t.references :member, index: true, null: false
t.references :project, index: true, null: false
t.timestamps_with_timezone null: false
t.integer :tasks, limit: 2, array: true, null: false, default: []
t.index [:member_id, :project_id], unique: true
end
end
end
# frozen_string_literal: true
class AddMemberIdForeignKeyToMemberTasks < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :member_tasks, :members, column: :member_id, on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :member_tasks, column: :member_id
end
end
end
# frozen_string_literal: true
class AddProjectIdForeignKeyToMemberTasks < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :member_tasks, :projects, column: :project_id, on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :member_tasks, column: :project_id
end
end
end
3744a9e1da77adb40fb7c3f9e3b6116fe0ced1b9e601c3f44fbdc6a31c30c632
\ No newline at end of file
97d8d04db020bc7dbcecf27b69fa82df0667c7c8a0862ca69cdccbb99d7d0c9c
\ No newline at end of file
72358f01061f5296e21647d5da9bbb6a33e94055c9c9aded6088cfb9126564b2
\ No newline at end of file
f4fe6c4a2860dd35f767d98d5025326142cab7fc9c12b5efb1541e2604791691
\ No newline at end of file
59e5de7766dc55e820ec714fbb61b5db61a73959f1e877e66caf668f93d0d633
\ No newline at end of file
...@@ -15671,6 +15671,24 @@ CREATE SEQUENCE lists_id_seq ...@@ -15671,6 +15671,24 @@ CREATE SEQUENCE lists_id_seq
ALTER SEQUENCE lists_id_seq OWNED BY lists.id; ALTER SEQUENCE lists_id_seq OWNED BY lists.id;
CREATE TABLE member_tasks (
id bigint NOT NULL,
member_id bigint NOT NULL,
project_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
tasks smallint[] DEFAULT '{}'::smallint[] NOT NULL
);
CREATE SEQUENCE member_tasks_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE member_tasks_id_seq OWNED BY member_tasks.id;
CREATE TABLE members ( CREATE TABLE members (
id integer NOT NULL, id integer NOT NULL,
access_level integer NOT NULL, access_level integer NOT NULL,
...@@ -15690,9 +15708,7 @@ CREATE TABLE members ( ...@@ -15690,9 +15708,7 @@ CREATE TABLE members (
ldap boolean DEFAULT false NOT NULL, ldap boolean DEFAULT false NOT NULL,
override boolean DEFAULT false NOT NULL, override boolean DEFAULT false NOT NULL,
state smallint DEFAULT 0, 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 CREATE SEQUENCE members_id_seq
...@@ -21503,6 +21519,8 @@ ALTER TABLE ONLY lists ALTER COLUMN id SET DEFAULT nextval('lists_id_seq'::regcl ...@@ -21503,6 +21519,8 @@ ALTER TABLE ONLY lists ALTER COLUMN id SET DEFAULT nextval('lists_id_seq'::regcl
ALTER TABLE ONLY loose_foreign_keys_deleted_records ALTER COLUMN id SET DEFAULT nextval('loose_foreign_keys_deleted_records_id_seq'::regclass); ALTER TABLE ONLY loose_foreign_keys_deleted_records ALTER COLUMN id SET DEFAULT nextval('loose_foreign_keys_deleted_records_id_seq'::regclass);
ALTER TABLE ONLY member_tasks ALTER COLUMN id SET DEFAULT nextval('member_tasks_id_seq'::regclass);
ALTER TABLE ONLY members ALTER COLUMN id SET DEFAULT nextval('members_id_seq'::regclass); ALTER TABLE ONLY members ALTER COLUMN id SET DEFAULT nextval('members_id_seq'::regclass);
ALTER TABLE ONLY merge_request_assignees ALTER COLUMN id SET DEFAULT nextval('merge_request_assignees_id_seq'::regclass); ALTER TABLE ONLY merge_request_assignees ALTER COLUMN id SET DEFAULT nextval('merge_request_assignees_id_seq'::regclass);
...@@ -23205,6 +23223,9 @@ ALTER TABLE ONLY list_user_preferences ...@@ -23205,6 +23223,9 @@ ALTER TABLE ONLY list_user_preferences
ALTER TABLE ONLY lists ALTER TABLE ONLY lists
ADD CONSTRAINT lists_pkey PRIMARY KEY (id); ADD CONSTRAINT lists_pkey PRIMARY KEY (id);
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT member_tasks_pkey PRIMARY KEY (id);
ALTER TABLE ONLY members ALTER TABLE ONLY members
ADD CONSTRAINT members_pkey PRIMARY KEY (id); ADD CONSTRAINT members_pkey PRIMARY KEY (id);
...@@ -25651,6 +25672,12 @@ CREATE INDEX index_lists_on_milestone_id ON lists USING btree (milestone_id); ...@@ -25651,6 +25672,12 @@ CREATE INDEX index_lists_on_milestone_id ON lists USING btree (milestone_id);
CREATE INDEX index_lists_on_user_id ON lists USING btree (user_id); CREATE INDEX index_lists_on_user_id ON lists USING btree (user_id);
CREATE INDEX index_member_tasks_on_member_id ON member_tasks USING btree (member_id);
CREATE UNIQUE INDEX index_member_tasks_on_member_id_and_project_id ON member_tasks USING btree (member_id, project_id);
CREATE INDEX index_member_tasks_on_project_id ON member_tasks USING btree (project_id);
CREATE INDEX index_members_on_access_level ON members USING btree (access_level); CREATE INDEX index_members_on_access_level ON members USING btree (access_level);
CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at); CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at);
...@@ -25663,8 +25690,6 @@ CREATE INDEX index_members_on_requested_at ON members USING btree (requested_at) ...@@ -25663,8 +25690,6 @@ 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_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_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)); 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));
...@@ -27576,9 +27601,6 @@ ALTER TABLE ONLY notification_settings ...@@ -27576,9 +27601,6 @@ ALTER TABLE ONLY notification_settings
ALTER TABLE ONLY lists ALTER TABLE ONLY lists
ADD CONSTRAINT fk_0d3f677137 FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE; 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 ALTER TABLE ONLY ci_unit_test_failures
ADD CONSTRAINT fk_0f09856e1f FOREIGN KEY (build_id) REFERENCES ci_builds(id) ON DELETE CASCADE; ADD CONSTRAINT fk_0f09856e1f FOREIGN KEY (build_id) REFERENCES ci_builds(id) ON DELETE CASCADE;
...@@ -27588,6 +27610,9 @@ ALTER TABLE ONLY project_pages_metadata ...@@ -27588,6 +27610,9 @@ ALTER TABLE ONLY project_pages_metadata
ALTER TABLE ONLY group_deletion_schedules ALTER TABLE ONLY group_deletion_schedules
ADD CONSTRAINT fk_11e3ebfcdd FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ADD CONSTRAINT fk_11e3ebfcdd FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT fk_12816d4bbb FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE CASCADE;
ALTER TABLE ONLY vulnerabilities ALTER TABLE ONLY vulnerabilities
ADD CONSTRAINT fk_1302949740 FOREIGN KEY (last_edited_by_id) REFERENCES users(id) ON DELETE SET NULL; ADD CONSTRAINT fk_1302949740 FOREIGN KEY (last_edited_by_id) REFERENCES users(id) ON DELETE SET NULL;
...@@ -28068,6 +28093,9 @@ ALTER TABLE ONLY identities ...@@ -28068,6 +28093,9 @@ ALTER TABLE ONLY identities
ALTER TABLE ONLY boards ALTER TABLE ONLY boards
ADD CONSTRAINT fk_ab0a250ff6 FOREIGN KEY (iteration_cadence_id) REFERENCES iterations_cadences(id) ON DELETE CASCADE; ADD CONSTRAINT fk_ab0a250ff6 FOREIGN KEY (iteration_cadence_id) REFERENCES iterations_cadences(id) ON DELETE CASCADE;
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT fk_ab636303dd FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY dep_ci_build_trace_sections ALTER TABLE ONLY dep_ci_build_trace_sections
ADD CONSTRAINT fk_ab7c104e26 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; ADD CONSTRAINT fk_ab7c104e26 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
...@@ -43,8 +43,8 @@ POST /projects/:id/invitations ...@@ -43,8 +43,8 @@ POST /projects/:id/invitations
| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY | | `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). | | `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. | | `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_to_be_done` | array of strings | no | Tasks the inviter wants the member to focus on. The tasks are added as issues to a specified project. The possible values are: `ci`, `code` and `issues`. If specified, requires `tasks_project_id`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. | | `tasks_project_id` | integer | no | The project ID in which to create the task issues. If specified, requires `tasks_to_be_done`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
```shell ```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
...@@ -422,8 +422,8 @@ POST /projects/:id/members ...@@ -422,8 +422,8 @@ POST /projects/:id/members
| `expires_at` | string | no | A date string in the format `YEAR-MONTH-DAY` | | `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). | | `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. | | `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_to_be_done` | array of strings | no | Tasks the inviter wants the member to focus on. The tasks are added as issues to a specified project. The possible values are: `ci`, `code` and `issues`. If specified, requires `tasks_project_id`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. | | `tasks_project_id` | integer | no | The project ID in which to create the task issues. If specified, requires `tasks_to_be_done`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
```shell ```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
...@@ -87,4 +87,38 @@ RSpec.describe Members::CreateService do ...@@ -87,4 +87,38 @@ RSpec.describe Members::CreateService do
expect(project.users).to include(*project_users) expect(project.users).to include(*project_users)
end end
end end
context 'when assigning tasks to be done' do
let(:params) do
{
user_ids: project_users.map(&:id).join(','),
access_level: Gitlab::Access::DEVELOPER,
tasks_to_be_done: %w(ci code),
tasks_project_id: project.id,
invite_source: '_invite_source_'
}
end
before do
stub_experiments(invite_members_for_task: true)
end
context 'when passing many user ids' do
it 'creates 2 task issues', :aggregate_failures, :sidekiq_inline do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(anything, user.id, array_including(*project_users.map(&:id)))
.once
.and_call_original
expect { subject }.to change { project.issues.reload.count }.by(2)
expect(project.issues).to all have_attributes(
project: project,
author: user,
assignees: project_users
)
end
end
end
end end
...@@ -36,6 +36,10 @@ module Gitlab ...@@ -36,6 +36,10 @@ module Gitlab
def progress def progress
super(track_name: 'Admin') super(track_name: 'Admin')
end end
def invite_members?
invite_members_for_task_experiment_enabled?
end
end end
end end
end end
......
...@@ -58,13 +58,7 @@ module Gitlab ...@@ -58,13 +58,7 @@ module Gitlab
end end
def invite_members? def invite_members?
return unless user.can?(:admin_group_member, group) false
experiment(:invite_members_for_task, namespace: group) do |e|
e.candidate { true }
e.record!
e.run
end
end end
def invite_text def invite_text
...@@ -167,6 +161,16 @@ module Gitlab ...@@ -167,6 +161,16 @@ module Gitlab
link(s_('InProductMarketing|update your preferences'), preference_link) link(s_('InProductMarketing|update your preferences'), preference_link)
end end
def invite_members_for_task_experiment_enabled?
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
end end
end end
end end
......
...@@ -61,6 +61,10 @@ module Gitlab ...@@ -61,6 +61,10 @@ module Gitlab
][series] ][series]
end end
def invite_members?
invite_members_for_task_experiment_enabled?
end
private private
def project_link def project_link
......
...@@ -60,10 +60,6 @@ module Gitlab ...@@ -60,10 +60,6 @@ module Gitlab
s_('InProductMarketing|Feedback from users like you really improves our product. Thanks for your help!') s_('InProductMarketing|Feedback from users like you really improves our product. Thanks for your help!')
end end
def invite_members?
false
end
private private
def onboarding_progress def onboarding_progress
......
...@@ -77,10 +77,6 @@ module Gitlab ...@@ -77,10 +77,6 @@ module Gitlab
def progress def progress
super(current: series + 2, total: 4) super(current: series + 2, total: 4)
end end
def invite_members?
false
end
end end
end end
end end
......
...@@ -40,10 +40,6 @@ module Gitlab ...@@ -40,10 +40,6 @@ module Gitlab
def logo_path def logo_path
'mailers/in_product_marketing/team-0.png' 'mailers/in_product_marketing/team-0.png'
end end
def invite_members?
false
end
end end
end end
end end
......
...@@ -72,10 +72,6 @@ module Gitlab ...@@ -72,10 +72,6 @@ module Gitlab
def progress def progress
super(current: series + 2, total: 4) super(current: series + 2, total: 4)
end end
def invite_members?
false
end
end end
end end
end end
......
...@@ -40,10 +40,6 @@ module Gitlab ...@@ -40,10 +40,6 @@ module Gitlab
def logo_path def logo_path
'mailers/in_product_marketing/trial-0.png' 'mailers/in_product_marketing/trial-0.png'
end end
def invite_members?
false
end
end end
end end
end end
......
...@@ -65,6 +65,10 @@ module Gitlab ...@@ -65,6 +65,10 @@ module Gitlab
][series] ][series]
end end
def invite_members?
invite_members_for_task_experiment_enabled?
end
private private
def ci_link def ci_link
......
...@@ -18729,7 +18729,7 @@ msgstr "" ...@@ -18729,7 +18729,7 @@ msgstr ""
msgid "InviteMembersModal|Contribute to the codebase" msgid "InviteMembersModal|Contribute to the codebase"
msgstr "" msgstr ""
msgid "InviteMembersModal|Create an issue for your new team member to work on (optional)" msgid "InviteMembersModal|Create issues for your new team member to work on (optional)"
msgstr "" msgstr ""
msgid "InviteMembersModal|GitLab member or email address" msgid "InviteMembersModal|GitLab member or email address"
...@@ -18765,7 +18765,7 @@ msgstr "" ...@@ -18765,7 +18765,7 @@ msgstr ""
msgid "InviteMembersModal|Something went wrong" msgid "InviteMembersModal|Something went wrong"
msgstr "" 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}" msgid "InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}"
msgstr "" msgstr ""
msgid "InviteMembersModal|What would you like new member(s) to focus on? (optional)" msgid "InviteMembersModal|What would you like new member(s) to focus on? (optional)"
...@@ -40589,6 +40589,12 @@ msgstr "" ...@@ -40589,6 +40589,12 @@ msgstr ""
msgid "is not in the group enforcing Group Managed Account" msgid "is not in the group enforcing Group Managed Account"
msgstr "" msgstr ""
msgid "is not in the member group"
msgstr ""
msgid "is not the member project"
msgstr ""
msgid "is not valid. The iteration group has to match the iteration cadence group." msgid "is not valid. The iteration group has to match the iteration cadence group."
msgstr "" msgstr ""
......
...@@ -101,6 +101,10 @@ RSpec.describe Registrations::WelcomeController do ...@@ -101,6 +101,10 @@ RSpec.describe Registrations::WelcomeController do
context 'when tasks to be done are assigned' do context 'when tasks to be done are assigned' do
let!(:member1) { create(:group_member, user: user, tasks_to_be_done: %w(ci code)) } let!(:member1) { create(:group_member, user: user, tasks_to_be_done: %w(ci code)) }
before do
stub_experiments(invite_members_for_task: true)
end
it { is_expected.to redirect_to(issues_dashboard_path(assignee_username: user.username)) } it { is_expected.to redirect_to(issues_dashboard_path(assignee_username: user.username)) }
end end
end end
......
...@@ -34,5 +34,18 @@ FactoryBot.define do ...@@ -34,5 +34,18 @@ FactoryBot.define do
access_level { GroupMember::MINIMAL_ACCESS } access_level { GroupMember::MINIMAL_ACCESS }
end end
transient do
tasks_to_be_done { [] }
end
after(:build) do |group_member, evaluator|
if evaluator.tasks_to_be_done.present?
build(:member_task,
member: group_member,
project: build(:project, namespace: group_member.source),
tasks_to_be_done: evaluator.tasks_to_be_done)
end
end
end end
end end
# frozen_string_literal: true
FactoryBot.define do
factory :member_task do
member { association(:group_member, :invited) }
project { association(:project, namespace: member.source) }
tasks_to_be_done { [:ci, :code] }
end
end
...@@ -23,5 +23,15 @@ FactoryBot.define do ...@@ -23,5 +23,15 @@ FactoryBot.define do
trait :blocked do trait :blocked do
after(:build) { |project_member, _| project_member.user.block! } after(:build) { |project_member, _| project_member.user.block! }
end end
transient do
tasks_to_be_done { [] }
end
after(:build) do |project_member, evaluator|
if evaluator.tasks_to_be_done.present?
build(:member_task, member: project_member, project: project_member.source, tasks_to_be_done: evaluator.tasks_to_be_done)
end
end
end end
end end
...@@ -75,6 +75,7 @@ RSpec.describe 'factories' do ...@@ -75,6 +75,7 @@ RSpec.describe 'factories' do
group_member group_member
import_state import_state
issue_customer_relations_contact issue_customer_relations_contact
member_task
milestone_release milestone_release
namespace namespace
project_broken_repo project_broken_repo
......
...@@ -77,6 +77,9 @@ const sharedGroup = { id: '981' }; ...@@ -77,6 +77,9 @@ const sharedGroup = { id: '981' };
const createComponent = (data = {}, props = {}) => { const createComponent = (data = {}, props = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, { wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
},
propsData: { propsData: {
id, id,
name, name,
...@@ -87,7 +90,6 @@ const createComponent = (data = {}, props = {}) => { ...@@ -87,7 +90,6 @@ const createComponent = (data = {}, props = {}) => {
defaultAccessLevel, defaultAccessLevel,
noSelectionAreasOfFocus, noSelectionAreasOfFocus,
tasksToBeDoneOptions, tasksToBeDoneOptions,
newProjectPath,
projects, projects,
helpLink, helpLink,
...props, ...props,
...@@ -152,10 +154,10 @@ describe('InviteMembersModal', () => { ...@@ -152,10 +154,10 @@ describe('InviteMembersModal', () => {
const membersFormGroupDescription = () => findMembersFormGroup().props('description'); const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
const findTasksToBeDone = () => wrapper.findByTestId('tasks-to-be-done'); const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('tasks'); const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
const findProjectSelect = () => wrapper.findByTestId('project-select'); const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select');
const findNoProjectsAlert = () => wrapper.findByTestId('no-projects-alert'); const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert');
describe('rendering the modal', () => { describe('rendering the modal', () => {
beforeEach(() => { beforeEach(() => {
......
...@@ -66,41 +66,76 @@ RSpec.describe InviteMembersHelper do ...@@ -66,41 +66,76 @@ RSpec.describe InviteMembersHelper do
context 'tasks_to_be_done' do context 'tasks_to_be_done' do
subject(:output) { helper.common_invite_modal_dataset(source) } subject(:output) { helper.common_invite_modal_dataset(source) }
context 'for a group' do let_it_be(:source) { project }
let(:source) { create(:group, projects: [project]) }
before do
it 'has the expected attributes', :aggregate_failures do stub_experiments(invite_members_for_task: true)
expect(output[:tasks_to_be_done_options]).to eq( end
[
{ value: :code, text: 'Create/import code into a project (repository)' }, context 'when not logged in' do
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' }, before do
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' } allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' })
].to_json end
)
expect(output[:projects]).to eq( it "doesn't have the tasks to be done attributes" do
[{ id: project.id, title: project.title }].to_json expect(output[:tasks_to_be_done_options]).to be_nil
) expect(output[:projects]).to be_nil
expect(output[:new_project_path]).to eq( expect(output[:new_project_path]).to be_nil
new_project_path(namespace_id: source.id) end
) end
context 'when logged in but the open_modal param is not present' do
before do
allow(helper).to receive(:current_user).and_return(developer)
end
it "doesn't have the tasks to be done attributes" do
expect(output[:tasks_to_be_done_options]).to be_nil
expect(output[:projects]).to be_nil
expect(output[:new_project_path]).to be_nil
end end
end end
context 'for a project' do context 'when logged in and the open_modal param is present' do
let(:source) { project } before do
allow(helper).to receive(:current_user).and_return(developer)
it 'has the expected attributes', :aggregate_failures do allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' })
expect(output[:tasks_to_be_done_options]).to eq( end
[
{ value: :code, text: 'Create/import code into a project (repository)' }, context 'for a group' do
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' }, let_it_be(:source) { create(:group, projects: [project]) }
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json it 'has the expected attributes', :aggregate_failures do
) expect(output[:tasks_to_be_done_options]).to eq(
expect(output[:projects]).to eq( [
[{ id: project.id, title: project.title }].to_json { 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' },
expect(output[:new_project_path]).to eq('') { 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
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 end
end end
......
...@@ -71,7 +71,7 @@ RSpec.describe MembersHelper do ...@@ -71,7 +71,7 @@ RSpec.describe MembersHelper do
describe '#localized_tasks_to_be_done_choices' do describe '#localized_tasks_to_be_done_choices' do
it 'has a translation for all `TASKS_TO_BE_DONE` keys' 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) expect(localized_tasks_to_be_done_choices).to include(*MemberTask::TASKS.keys)
end end
end end
end end
...@@ -133,6 +133,7 @@ project_members: ...@@ -133,6 +133,7 @@ project_members:
- user - user
- source - source
- project - project
- member_task
merge_requests: merge_requests:
- status_check_responses - status_check_responses
- subscriptions - subscriptions
......
...@@ -723,14 +723,15 @@ RSpec.describe Group do ...@@ -723,14 +723,15 @@ RSpec.describe Group do
let!(:project) { create(:project, group: group) } let!(:project) { create(:project, group: group) }
before do before do
group.add_users([user], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id) stub_experiments(invite_members_for_task: true)
group.add_users([create(:user)], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
end end
it 'updates the attributes', :aggregate_failures do it 'creates a member_task with the correct attributes', :aggregate_failures do
member = group.group_members.last member = group.group_members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code]) expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(project) expect(member.member_task.project).to eq(project)
end end
end end
end end
......
...@@ -9,7 +9,7 @@ RSpec.describe Member do ...@@ -9,7 +9,7 @@ RSpec.describe Member do
describe 'Associations' do describe 'Associations' do
it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:tasks_project).class_name('Project') } it { is_expected.to have_one(:member_task) }
end end
describe 'Validation' do describe 'Validation' do
...@@ -681,12 +681,13 @@ RSpec.describe Member do ...@@ -681,12 +681,13 @@ RSpec.describe Member do
end end
it 'schedules a TasksToBeDone::CreateWorker task' do it 'schedules a TasksToBeDone::CreateWorker task' do
member.tasks_to_be_done = %w(ci code) stub_experiments(invite_members_for_task: true)
member.tasks_project = member.project
member_task = create(:member_task, member: member, project: member.project)
expect(TasksToBeDone::CreateWorker) expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async) .to receive(:perform_async)
.with(member.project.id, member.created_by_id, [user.id], [:ci, :code]) .with(member_task.id, member.created_by_id, [user.id])
.once .once
member.accept_invite!(user) member.accept_invite!(user)
...@@ -798,66 +799,6 @@ RSpec.describe Member do ...@@ -798,66 +799,6 @@ RSpec.describe Member do
end end
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 describe 'destroying a record', :delete, :sidekiq_inline do
it "refreshes user's authorized projects" do it "refreshes user's authorized projects" do
project = create(:project, :private) project = create(:project, :private)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MemberTask do
describe 'Associations' do
it { is_expected.to belong_to(:member) }
it { is_expected.to belong_to(:project) }
end
describe 'Validations' do
it { is_expected.to validate_presence_of(:member) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_inclusion_of(:tasks).in_array(MemberTask::TASKS.values) }
describe 'unique tasks validation' do
subject do
build(:member_task, tasks: [0, 0])
end
it 'expects the task values to be unique' do
expect(subject).to be_invalid
expect(subject.errors[:tasks]).to include('are not unique')
end
end
describe 'project validations' do
let_it_be(:project) { create(:project) }
subject do
build(:member_task, member: member, project: project, tasks_to_be_done: [:ci, :code])
end
context 'when the member source is a group' do
let_it_be(:member) { create(:group_member) }
it "expects the project to be part of the member's group projects" do
expect(subject).to be_invalid
expect(subject.errors[:project]).to include('is not in the member group')
end
context "when the project is part of the member's group projects" do
let_it_be(:project) { create(:project, namespace: member.source) }
it { is_expected.to be_valid }
end
end
context 'when the member source is a project' do
let_it_be(:member) { create(:project_member) }
it "expects the project to be the member's project" do
expect(subject).to be_invalid
expect(subject.errors[:project]).to include('is not the member project')
end
context "when the project is the member's project" do
let_it_be(:project) { member.source }
it { is_expected.to be_valid }
end
end
end
end
describe '.for_members' do
it 'returns the member_tasks for multiple members' do
member1 = create(:group_member)
member_task1 = create(:member_task, member: member1)
create(:member_task)
expect(described_class.for_members([member1])).to match_array([member_task1])
end
end
describe '#tasks_to_be_done' do
subject { member_task.tasks_to_be_done }
let_it_be(:member_task) { build(:member_task) }
before do
member_task[:tasks] = [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_it_be(:member_task) { build(:member_task) }
context 'when passing valid values' do
subject { member_task[:tasks] }
before do
member_task.tasks_to_be_done = tasks
end
context 'when passing tasks as strings' do
let_it_be(: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_it_be(: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_it_be(:tasks) { %w(ci ci) }
it 'is set only once' do
expect(subject).to match_array([1])
end
end
end
end
end
...@@ -237,14 +237,15 @@ RSpec.describe ProjectTeam do ...@@ -237,14 +237,15 @@ RSpec.describe ProjectTeam do
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
before do before do
stub_experiments(invite_members_for_task: true)
project.team.add_users([user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id) project.team.add_users([user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
end end
it 'updates the attributes', :aggregate_failures do it 'creates a member_task with the correct attributes', :aggregate_failures do
member = project.project_members.last member = project.project_members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code]) expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(project) expect(member.member_task.project).to eq(project)
end end
end end
end end
......
...@@ -167,26 +167,32 @@ RSpec.describe API::Invitations do ...@@ -167,26 +167,32 @@ RSpec.describe API::Invitations do
end end
context 'with tasks_to_be_done and tasks_project_id in the params' do context 'with tasks_to_be_done and tasks_project_id in the params' do
before do
stub_experiments(invite_members_for_task: true)
end
let(:project_id) { source_type == 'project' ? source.id : create(:project, namespace: source).id }
context 'when there is 1 invitation' do context 'when there is 1 invitation' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do it 'creates a member_task with the tasks_to_be_done and the project' do
post invitations_url(source, maintainer), post invitations_url(source, maintainer),
params: { email: email, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project.id } 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) member = source.members.find_by(invite_email: email)
expect(member.tasks_to_be_done).to match_array([:code, :ci]) expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id) expect(member.member_task.project_id).to eq(project_id)
end end
end end
context 'when there are multiple invitations' do context 'when there are multiple invitations' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do it 'creates a member_task with the tasks_to_be_done and the project' do
post invitations_url(source, maintainer), 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 } 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 = source.members.where(invite_email: [email, email2])
members.each do |member| members.each do |member|
expect(member.tasks_to_be_done).to match_array([:code, :ci]) expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id) expect(member.member_task.project_id).to eq(project_id)
end end
end end
end end
......
...@@ -407,26 +407,32 @@ RSpec.describe API::Members do ...@@ -407,26 +407,32 @@ RSpec.describe API::Members do
end end
context 'with tasks_to_be_done and tasks_project_id in the params' do context 'with tasks_to_be_done and tasks_project_id in the params' do
before do
stub_experiments(invite_members_for_task: true)
end
let(:project_id) { source_type == 'project' ? source.id : create(:project, namespace: source).id }
context 'when there is 1 user to add' do context 'when there is 1 user to add' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do it 'creates a member_task with the correct attributes' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), 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 } 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) member = source.members.find_by(user_id: stranger.id)
expect(member.tasks_to_be_done).to match_array([:code, :ci]) expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id) expect(member.member_task.project_id).to eq(project_id)
end end
end end
context 'when there are multiple users to add' do context 'when there are multiple users to add' do
it 'saves the tasks_to_be_done and the tasks_projects_id' do it 'creates a member_task with the correct attributes' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), 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 } 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 = source.members.where(user_id: [developer.id, stranger.id])
members.each do |member| members.each do |member|
expect(member.tasks_to_be_done).to match_array([:code, :ci]) expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.tasks_project_id).to eq(project.id) expect(member.member_task.project_id).to eq(project_id)
end end
end end
end end
......
...@@ -202,38 +202,46 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -202,38 +202,46 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code), tasks_project_id: source.id } { invite_source: '_invite_source_', tasks_to_be_done: %w(ci code), tasks_project_id: source.id }
end end
before do
stub_experiments(invite_members_for_task: true)
end
it 'creates 2 task issues', :aggregate_failures do it 'creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker) expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async) .to receive(:perform_async)
.with(source.id, user.id, [member.id], %w(ci code)) .with(anything, user.id, [member.id])
.once .once
.and_call_original .and_call_original
expect { execute_service }.to change { source.issues.count }.by(2) expect { execute_service }.to change { source.issues.count }.by(2)
source.issues.each do |issue| expect(source.issues).to all have_attributes(
expect(issue.project).to eq(source) project: source,
expect(issue.author).to eq(user) author: user,
expect(issue.assignees).to match_array([member]) assignees: array_including(member)
end )
end end
context 'when passing many user ids' do context 'when passing many user ids' do
before do
stub_licensed_features(multiple_issue_assignees: false)
end
let(:another_user) { create(:user) } let(:another_user) { create(:user) }
let(:user_ids) { [member.id, another_user.id].join(',') } let(:user_ids) { [member.id, another_user.id].join(',') }
it 'still creates 2 task issues', :aggregate_failures do it 'still creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker) expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async) .to receive(:perform_async)
.with(source.id, user.id, [member.id, another_user.id], %w(ci code)) .with(anything, user.id, array_including(member.id, another_user.id))
.once .once
.and_call_original .and_call_original
expect { execute_service }.to change { source.issues.count }.by(2) expect { execute_service }.to change { source.issues.count }.by(2)
source.issues.each do |issue| expect(source.issues).to all have_attributes(
expect(issue.project).to eq(source) project: source,
expect(issue.author).to eq(user) author: user,
expect(issue.assignees).to match_array([member, another_user]) assignees: array_including(member)
end )
end end
end end
...@@ -242,8 +250,55 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -242,8 +250,55 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code) } { invite_source: '_invite_source_', tasks_to_be_done: %w(ci code) }
end end
it 'still creates 2 task issues', :aggregate_failures do it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when `tasks_to_be_done` are missing' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when invalid `tasks_to_be_done` are passed' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(invalid_task) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when invalid `tasks_project_id` is passed' do
let(:another_project) { create(:project) }
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: another_project.id, tasks_to_be_done: %w(ci code) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when a member was already invited' do
let(:user_ids) { create(:project_member, :invited, project: source).invite_email }
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(ci code) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end end
end end
end end
......
...@@ -8,6 +8,7 @@ RSpec.describe TasksToBeDone::BaseService do ...@@ -8,6 +8,7 @@ RSpec.describe TasksToBeDone::BaseService do
let_it_be(:assignee_one) { create(:user) } let_it_be(:assignee_one) { create(:user) }
let_it_be(:assignee_two) { create(:user) } let_it_be(:assignee_two) { create(:user) }
let_it_be(:assignee_ids) { [assignee_one.id] } let_it_be(:assignee_ids) { [assignee_one.id] }
let_it_be(:label) { create(:label, title: 'tasks to be done:ci', project: project) }
before do before do
project.add_maintainer(current_user) project.add_maintainer(current_user)
...@@ -28,7 +29,8 @@ RSpec.describe TasksToBeDone::BaseService do ...@@ -28,7 +29,8 @@ RSpec.describe TasksToBeDone::BaseService do
params = { params = {
assignee_ids: assignee_ids, assignee_ids: assignee_ids,
title: 'Set up CI/CD', title: 'Set up CI/CD',
description: anything description: anything,
add_labels: label.title
} }
expect(Issues::BuildService) expect(Issues::BuildService)
...@@ -38,18 +40,20 @@ RSpec.describe TasksToBeDone::BaseService do ...@@ -38,18 +40,20 @@ RSpec.describe TasksToBeDone::BaseService do
expect { service.execute }.to change(Issue, :count).by(1) expect { service.execute }.to change(Issue, :count).by(1)
issue = project.issues.last expect(project.issues.last).to have_attributes(
expect(issue.author).to eq(current_user) author: current_user,
expect(issue.title).to eq('Set up CI/CD') title: params[:title],
expect(issue.assignees).to eq([assignee_one]) assignees: [assignee_one],
labels: [label]
)
end end
end end
context 'an issue with the same title already exists', :aggregate_failures do context 'an open issue with the same label already exists', :aggregate_failures do
let_it_be(:assignee_ids) { [assignee_two.id] } let_it_be(:assignee_ids) { [assignee_two.id] }
it 'assigns the user to the existing issue' do 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]) issue = create(:labeled_issue, project: project, labels: [label], assignees: [assignee_one])
params = { add_assignee_ids: assignee_ids } params = { add_assignee_ids: assignee_ids }
expect(Issues::UpdateService) expect(Issues::UpdateService)
......
...@@ -286,6 +286,7 @@ licenses: :gitlab_main ...@@ -286,6 +286,7 @@ licenses: :gitlab_main
lists: :gitlab_main lists: :gitlab_main
list_user_preferences: :gitlab_main list_user_preferences: :gitlab_main
loose_foreign_keys_deleted_records: :gitlab_main loose_foreign_keys_deleted_records: :gitlab_main
member_tasks: :gitlab_main
members: :gitlab_main members: :gitlab_main
merge_request_assignees: :gitlab_main merge_request_assignees: :gitlab_main
merge_request_blocks: :gitlab_main merge_request_blocks: :gitlab_main
......
...@@ -301,14 +301,18 @@ RSpec.shared_examples_for "member creation" do ...@@ -301,14 +301,18 @@ RSpec.shared_examples_for "member creation" do
end end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
it 'updates the attributes', :aggregate_failures do before do
stub_experiments(invite_members_for_task: true)
end
it 'creates a member_task with the correct attributes', :aggregate_failures do
task_project = source.is_a?(Group) ? create(:project, group: source) : source 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 described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute
member = source.members.last member = source.members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code]) expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(task_project) expect(member.member_task.project).to eq(task_project)
end end
end end
end end
...@@ -393,13 +397,17 @@ RSpec.shared_examples_for "bulk member creation" do ...@@ -393,13 +397,17 @@ RSpec.shared_examples_for "bulk member creation" do
end end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
it 'updates the attributes', :aggregate_failures do before do
stub_experiments(invite_members_for_task: true)
end
it 'creates a member_task with the correct attributes', :aggregate_failures do
task_project = source.is_a?(Group) ? create(:project, group: source) : source 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) members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id)
member = members.last member = members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code]) expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.tasks_project).to eq(task_project) expect(member.member_task.project).to eq(task_project)
end end
end end
end end
......
...@@ -3,28 +3,34 @@ ...@@ -3,28 +3,34 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe TasksToBeDone::CreateWorker do RSpec.describe TasksToBeDone::CreateWorker do
subject(:worker) { described_class.new } let_it_be(:member_task) { create(:member_task, tasks: MemberTask::TASKS.values) }
let_it_be(:current_user) { create(:user) }
let(:assignee_ids) { [1, 2] }
let(:job_args) { [member_task.id, current_user.id, assignee_ids] }
before do
member_task.project.group.add_owner(current_user)
end
describe '.perform' do describe '.perform' do
let(:project) { create(:project) } it 'executes the task services for all tasks to be done', :aggregate_failures do
let(:current_user) { create(:user) } MemberTask::TASKS.each_key do |task|
let(:assignee_ids) { [1, 2] } service_class = "TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize
it 'executes the task services for all available tasks to be done', :aggregate_failures do expect(service_class)
expect(TasksToBeDone::CreateCodeTaskService) .to receive(:new)
.to receive(:new) .with(project: member_task.project, current_user: current_user, assignee_ids: assignee_ids)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids) .and_call_original
.and_call_original end
expect(TasksToBeDone::CreateCiTaskService)
.to receive(:new) expect { described_class.new.perform(*job_args) }.to change(Issue, :count).by(3)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids) end
.and_call_original end
expect(TasksToBeDone::CreateIssuesTaskService)
.to receive(:new) include_examples 'an idempotent worker' do
.with(project: project, current_user: current_user, assignee_ids: assignee_ids) it 'creates 3 task issues' do
.and_call_original expect { subject }.to change(Issue, :count).by(3)
worker.perform(project.id, current_user.id, assignee_ids, Member::TASKS_TO_BE_DONE.keys)
end end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment