Commit 0d3ecf58 authored by Clement Ho's avatar Clement Ho

Merge branch 'multiple_assignees_review' into multiple-assignees-fe-sidebar

parents a0f6bf9d d525c9a8
...@@ -20,6 +20,7 @@ import eventHub from '../eventhub'; ...@@ -20,6 +20,7 @@ import eventHub from '../eventhub';
list: { list: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}),
}, },
rootPath: { rootPath: {
type: String, type: String,
...@@ -31,7 +32,67 @@ import eventHub from '../eventhub'; ...@@ -31,7 +32,67 @@ import eventHub from '../eventhub';
default: false, default: false,
}, },
}, },
data() {
return {
limitBeforeCounter: 3,
maxRender: 4,
maxCounter: 99,
};
},
computed: {
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
return `${this.assigneeCounterLabel} more`;
},
assigneeCounterLabel() {
if (this.numberOverLimit > this.maxCounter) {
return `${this.maxCounter}+`;
}
return `+${this.numberOverLimit}`;
},
shouldRenderCounter() {
if (this.issue.assignees.length <= this.maxRender) {
return false;
}
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
return `${this.issueLinkBase}/${this.issue.id}`;
},
issueId() {
return `#${this.issue.id}`;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
},
},
methods: { methods: {
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
shouldRenderAssignee(index) {
// Eg. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
if (this.issue.assignees.length <= this.maxRender) {
return index < this.maxRender;
}
return index < this.limitBeforeCounter;
},
assigneeUrl(assignee) {
return `${this.rootPath}${assignee.username}`;
},
assigneeUrlTitle(assignee) {
return `Assigned to ${assignee.name}`;
},
avatarUrlTitle(assignee) {
return `Avatar for ${assignee.name}`;
},
showLabel(label) { showLabel(label) {
if (!this.list) return true; if (!this.list) return true;
...@@ -67,35 +128,55 @@ import eventHub from '../eventhub'; ...@@ -67,35 +128,55 @@ import eventHub from '../eventhub';
}, },
template: ` template: `
<div> <div>
<h4 class="card-title"> <div class="card-header">
<i <h4 class="card-title">
class="fa fa-eye-slash confidential-icon" <i
v-if="issue.confidential"></i> class="fa fa-eye-slash confidential-icon"
<a v-if="issue.confidential"
:href="issueLinkBase + '/' + issue.id" aria-hidden="true"
:title="issue.title"> />
{{ issue.title }} <a
</a> class="js-no-trigger"
</h4> :href="cardUrl"
<div class="card-footer"> :title="issue.title">{{ issue.title }}</a>
<span <span
class="card-number" class="card-number"
v-if="issue.id"> v-if="issue.id"
#{{ issue.id }} >
</span> {{ issueId }}
<a </span>
class="card-assignee has-tooltip" </h4>
:href="rootPath + issue.assignee.username" <div class="card-assignee">
:title="'Assigned to ' + issue.assignee.name" <a
v-if="issue.assignee" class="has-tooltip js-no-trigger"
data-container="body"> :href="assigneeUrl(assignee)"
<img :title="assigneeUrlTitle(assignee)"
class="avatar avatar-inline s20" v-for="(assignee, index) in issue.assignees"
:src="issue.assignee.avatar" v-if="shouldRenderAssignee(index)"
width="20" data-container="body"
height="20" data-placement="bottom"
:alt="'Avatar for ' + issue.assignee.name" /> >
</a> <img
class="avatar avatar-inline s20"
:src="assignee.avatar"
width="20"
height="20"
:alt="avatarUrlTitle(assignee)"
/>
</a>
<span
class="avatar-counter has-tooltip"
:title="assigneeCounterTooltip"
v-if="shouldRenderCounter"
>
{{ assigneeCounterLabel }}
</span>
</div>
</div>
<div
class="card-footer"
v-if="showLabelFooter"
>
<button <button
class="label color-label has-tooltip" class="label color-label has-tooltip"
v-for="label in issue.labels" v-for="label in issue.labels"
......
...@@ -15,14 +15,9 @@ class ListIssue { ...@@ -15,14 +15,9 @@ class ListIssue {
this.subscribed = obj.subscribed; this.subscribed = obj.subscribed;
this.labels = []; this.labels = [];
this.selected = false; this.selected = false;
this.assignee = false;
this.position = obj.relative_position || Infinity; this.position = obj.relative_position || Infinity;
this.milestone_id = obj.milestone_id; this.milestone_id = obj.milestone_id;
if (obj.assignee) {
this.assignee = new ListUser(obj.assignee);
}
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
} }
...@@ -30,6 +25,8 @@ class ListIssue { ...@@ -30,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => { obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label)); this.labels.push(new ListLabel(label));
}); });
this.assignees = obj.assignees.map(a => new ListUser(a));
} }
addLabel (label) { addLabel (label) {
......
...@@ -226,7 +226,7 @@ ...@@ -226,7 +226,7 @@
.card { .card {
position: relative; position: relative;
padding: 10px $gl-padding; padding: 11px 10px 11px $gl-padding;
background: $white-light; background: $white-light;
border-radius: $border-radius-default; border-radius: $border-radius-default;
box-shadow: 0 1px 2px $issue-boards-card-shadow; box-shadow: 0 1px 2px $issue-boards-card-shadow;
...@@ -236,8 +236,13 @@ ...@@ -236,8 +236,13 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
&.is-active { &.is-active,
&.is-active .card-assignee:hover a {
background-color: $row-hover; background-color: $row-hover;
&:first-child:not(:only-child) {
box-shadow: -10px 0 10px 1px $row-hover;
}
} }
.label { .label {
...@@ -246,36 +251,111 @@ ...@@ -246,36 +251,111 @@
} }
.confidential-icon { .confidential-icon {
position: relative;
top: 1px;
margin-right: 5px; margin-right: 5px;
} }
} }
.card-title { .card-title {
margin: 0; margin: 0 30px 0 0;
font-size: 1em; font-size: 1em;
line-height: inherit;
a { a {
color: inherit; color: $gl-text-color;
word-wrap: break-word; word-wrap: break-word;
margin-right: 2px;
} }
} }
.card-footer { .card-header {
margin-top: 5px; display: flex;
line-height: 25px; min-height: 20px;
.label { .card-assignee {
margin-right: 5px; display: flex;
font-size: (14px / $issue-boards-font-size) * 1em; justify-content: flex-end;
position: absolute;
right: 15px;
height: 20px;
width: 20px;
.avatar-counter {
display: none;
vertical-align: middle;
min-width: 20px;
line-height: 19px;
height: 20px;
padding-left: 2px;
padding-right: 2px;
border-radius: 2em;
}
img {
vertical-align: top;
}
a {
position: relative;
margin-left: -15px;
}
a:nth-child(1) {
z-index: 3;
}
a:nth-child(2) {
z-index: 2;
}
a:nth-child(3) {
z-index: 1;
}
a:nth-child(4) {
display: none;
}
&:hover {
.avatar-counter {
display: inline-block;
}
a {
position: static;
background-color: $white-light;
transition: background-color 0s;
margin-left: auto;
&:nth-child(4) {
display: block;
}
&:first-child:not(:only-child) {
box-shadow: -10px 0 10px 1px $white-light;
}
}
}
} }
.avatar { .avatar {
margin-left: 0; margin: 0;
}
}
.card-footer {
margin: 0 0 5px;
.label {
margin-top: 5px;
margin-right: 6px;
} }
} }
.card-number { .card-number {
margin-right: 5px; font-size: 12px;
color: $gl-text-color-secondary;
} }
.issue-boards-search { .issue-boards-search {
......
...@@ -231,6 +231,17 @@ class IssuableFinder ...@@ -231,6 +231,17 @@ class IssuableFinder
klass.all klass.all
end end
def by_scope(items)
case params[:scope]
when 'created-by-me', 'authored'
items.where(author_id: current_user.id)
when 'assigned-to-me'
items.assigned_to(current_user)
else
items
end
end
def by_state(items) def by_state(items)
case params[:state].to_s case params[:state].to_s
when 'closed' when 'closed'
......
...@@ -28,40 +28,26 @@ class IssuesFinder < IssuableFinder ...@@ -28,40 +28,26 @@ class IssuesFinder < IssuableFinder
def by_assignee(items) def by_assignee(items)
if assignee if assignee
items = items.where("issue_assignees.user_id = ?", assignee.id) items.assigned_to(assignee)
elsif no_assignee? elsif no_assignee?
items = items.where("issue_assignees.user_id is NULL") items.unassigned
elsif assignee_id? || assignee_username? # assignee not found elsif assignee_id? || assignee_username? # assignee not found
items = items.none items.none
end
items
end
def by_scope(items)
case params[:scope]
when 'created-by-me', 'authored'
items.where(author_id: current_user.id)
when 'assigned-to-me'
items.where("issue_assignees.user_id = ?", current_user.id)
else else
items items
end end
end end
def self.not_restricted_by_confidentiality(user) def self.not_restricted_by_confidentiality(user)
issues = Issue.with_assignees return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
return issues.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
return issues.all if user.admin_or_auditor? return Issue.all if user.admin_or_auditor?
issues.where(' Issue.where('
issues.confidential IS NULL issues.confidential IS NOT TRUE
OR issues.confidential IS FALSE
OR (issues.confidential = TRUE OR (issues.confidential = TRUE
AND (issues.author_id = :user_id AND (issues.author_id = :user_id
OR issue_assignees.user_id = :user_id OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
OR issues.project_id IN(:project_ids)))', OR issues.project_id IN(:project_ids)))',
user_id: user.id, user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
......
...@@ -23,17 +23,6 @@ class MergeRequestsFinder < IssuableFinder ...@@ -23,17 +23,6 @@ class MergeRequestsFinder < IssuableFinder
private private
def by_scope(items)
case params[:scope]
when 'created-by-me', 'authored'
items.where(author_id: current_user.id)
when 'assigned-to-me'
items.where(assignee_id: current_user.id)
else
items
end
end
def item_project_ids(items) def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id) items&.reorder(nil)&.select(:target_project_id)
end end
......
...@@ -40,7 +40,7 @@ module Milestoneish ...@@ -40,7 +40,7 @@ module Milestoneish
def issues_visible_to_user(user) def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params) IssuesFinder.new(user, issues_finder_params)
.execute.where(milestone_id: milestoneish_ids) .execute.includes(:assignees).where(milestone_id: milestoneish_ids)
end end
end end
......
...@@ -36,7 +36,7 @@ class GlobalMilestone ...@@ -36,7 +36,7 @@ class GlobalMilestone
closed = count_by_state(milestones_by_state_and_title, 'closed') closed = count_by_state(milestones_by_state_and_title, 'closed')
all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
{ {
opened: opened, opened: opened,
closed: closed, closed: closed,
all: all all: all
...@@ -94,7 +94,7 @@ class GlobalMilestone ...@@ -94,7 +94,7 @@ class GlobalMilestone
end end
def participants def participants
@participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq @participants ||= milestones.map(&:participants).flatten.uniq
end end
def labels def labels
......
...@@ -34,13 +34,11 @@ class Issue < ActiveRecord::Base ...@@ -34,13 +34,11 @@ class Issue < ActiveRecord::Base
validates :project, presence: true validates :project, presence: true
scope :cared, ->(user) { with_assignees.where("issue_assignees.user_id IN(?)", user.id) }
scope :open_for, ->(user) { opened.assigned_to(user) } scope :open_for, ->(user) { opened.assigned_to(user) }
scope :in_projects, ->(project_ids) { where(project_id: project_ids) } scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :with_assignees, -> { joins("LEFT JOIN issue_assignees ON issue_id = issues.id") } scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
scope :assigned, -> { with_assignees.where('issue_assignees.user_id IS NOT NULL') } scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
scope :unassigned, -> { with_assignees.where('issue_assignees.user_id IS NULL') } scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
scope :assigned_to, ->(u) { with_assignees.where('issue_assignees.user_id = ?', u.id)}
scope :without_due_date, -> { where(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_before, ->(date) { where('issues.due_date < ?', date) }
......
...@@ -106,7 +106,7 @@ class User < ActiveRecord::Base ...@@ -106,7 +106,7 @@ class User < ActiveRecord::Base
has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
has_many :issue_assignees, dependent: :destroy has_many :issue_assignees
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
......
...@@ -22,9 +22,9 @@ module Issues ...@@ -22,9 +22,9 @@ module Issues
end end
def filter_assignee(issuable) def filter_assignee(issuable)
return if params[:assignee_ids].to_a.empty? return if params[:assignee_ids].blank?
assignee_ids = params[:assignee_ids].select{ |assignee_id| assignee_can_read?(issuable, assignee_id)} assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
params[:assignee_ids] = [] params[:assignee_ids] = []
......
...@@ -21,7 +21,7 @@ module Issues ...@@ -21,7 +21,7 @@ module Issues
def csv_builder def csv_builder
@csv_builder ||= @csv_builder ||=
CsvBuilder.new(@issues.includes(:author), header_to_value_hash) CsvBuilder.new(@issues.includes(:author, :assignees), header_to_value_hash)
end end
private private
...@@ -35,8 +35,8 @@ module Issues ...@@ -35,8 +35,8 @@ module Issues
'Description' => 'description', 'Description' => 'description',
'Author' => 'author_name', 'Author' => 'author_name',
'Author Username' => -> (issue) { issue.author&.username }, 'Author Username' => -> (issue) { issue.author&.username },
'Assignee' => -> (issue) { issue.assignees.pluck(:name).join(', ') }, 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') },
'Assignee Username' => -> (issue) { issue.assignees.pluck(:username).join(', ') }, 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') },
'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
......
...@@ -22,7 +22,7 @@ module Issues ...@@ -22,7 +22,7 @@ module Issues
end end
if issue.previous_changes.include?('title') || if issue.previous_changes.include?('title') ||
issue.previous_changes.include?('description') issue.previous_changes.include?('description')
todo_service.update_issue(issue, current_user) todo_service.update_issue(issue, current_user)
end end
......
...@@ -4,7 +4,7 @@ module MergeRequests ...@@ -4,7 +4,7 @@ module MergeRequests
@assignable_issues ||= begin @assignable_issues ||= begin
if current_user == merge_request.author if current_user == merge_request.author
closes_issues.select do |issue| closes_issues.select do |issue|
!issue.is_a?(ExternalIssue) && !issue.assignees.any? && can?(current_user, :admin_issue, issue) !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue)
end end
else else
[] []
......
...@@ -29,7 +29,7 @@ class NotificationRecipientService ...@@ -29,7 +29,7 @@ class NotificationRecipientService
recipients << target.assignee recipients << target.assignee
when :reassign_issue when :reassign_issue
previous_assignees = Array(previous_assignee) previous_assignees = Array(previous_assignee)
recipients.concat(previous_assignees) if previous_assignees.any? recipients.concat(previous_assignees)
recipients.concat(target.assignees) recipients.concat(target.assignees)
end end
......
...@@ -86,15 +86,18 @@ module SlashCommands ...@@ -86,15 +86,18 @@ module SlashCommands
current_user.can?(:"admin_#{issuable.to_ability_name}", project) current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end end
command :assign do |assignee_param| command :assign do |assignee_param|
user = extract_references(assignee_param, :user).first user_ids = extract_references(assignee_param, :user).map(&:id)
user ||= User.find_by(username: assignee_param)
next unless user if user_ids.empty?
user_ids = User.where(username: assignee_param.split(' ').map(&:strip)).pluck(:id)
end
next if user_ids.empty?
if issuable.is_a?(Issue) if issuable.is_a?(Issue)
@updates[:assignee_ids] = [user.id] @updates[:assignee_ids] = user_ids
else else
@updates[:assignee_id] = user.id @updates[:assignee_id] = user_ids.last
end end
end end
......
...@@ -54,7 +54,7 @@ module SystemNoteService ...@@ -54,7 +54,7 @@ module SystemNoteService
# issue - Issue object # issue - Issue object
# project - Project owning noteable # project - Project owning noteable
# author - User performing the change # author - User performing the change
# assignees - User being assigned, or nil # assignees - Users being assigned, or nil
# #
# Example Note text: # Example Note text:
# #
......
Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
...@@ -7,6 +7,6 @@ ...@@ -7,6 +7,6 @@
- if @issue.description - if @issue.description
= markdown(@issue.description, pipeline: :email, author: @issue.author) = markdown(@issue.description, pipeline: :email, author: @issue.author)
- if @issue.assignees.any? - if @issue.assignees.present?
%p %p
Assignee: #{@issue.assignee_list} Assignee: #{@issue.assignee_list}
...@@ -8,4 +8,3 @@ ...@@ -8,4 +8,3 @@
%strong= @issue.assignee_list %strong= @issue.assignee_list
- else - else
%strong Unassigned %strong Unassigned
<%= render 'reassigned_issuable_email', issuable: @issue %> Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
= render 'reassigned_issuable_email', issuable: @merge_request Reassigned Merge Request #{ @merge_request.iid }
= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }])
Assignee changed
- if @previous_assignee
from #{@previous_assignee.name}
to
= @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'
---
title: Update issue board cards design
merge_request: 10353
author:
...@@ -26,7 +26,7 @@ class CreateIssueAssigneesTable < ActiveRecord::Migration ...@@ -26,7 +26,7 @@ class CreateIssueAssigneesTable < ActiveRecord::Migration
# disable_ddl_transaction! # disable_ddl_transaction!
def up def up
create_table :issue_assignees do |t| create_table :issue_assignees, id: false do |t|
t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false
t.references :issue, foreign_key: { on_delete: :cascade }, null: false t.references :issue, foreign_key: { on_delete: :cascade }, null: false
end end
...@@ -35,10 +35,6 @@ class CreateIssueAssigneesTable < ActiveRecord::Migration ...@@ -35,10 +35,6 @@ class CreateIssueAssigneesTable < ActiveRecord::Migration
end end
def down def down
if index_exists?(:issue_assignees, name: INDEX_NAME)
remove_index :issue_assignees, name: INDEX_NAME
end
drop_table :issue_assignees drop_table :issue_assignees
end end
end end
...@@ -21,9 +21,23 @@ class MigrateAssignees < ActiveRecord::Migration ...@@ -21,9 +21,23 @@ class MigrateAssignees < ActiveRecord::Migration
# #
# To disable transactions uncomment the following line and remove these # To disable transactions uncomment the following line and remove these
# comments: # comments:
# disable_ddl_transaction! disable_ddl_transaction!
def up def up
# Optimisation: this accounts for most of the invalid assignee IDs on GitLab.com
update_column_in_batches(:issues, :assignee_id, nil) do |table, query|
query.where(table[:assignee_id].eq(0))
end
users = Arel::Table.new(:users)
update_column_in_batches(:issues, :assignee_id, nil) do |table, query|
query.where(table[:assignee_id].not_eq(nil)\
.and(
users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not
))
end
execute <<-EOF execute <<-EOF
INSERT INTO issue_assignees(issue_id, user_id) INSERT INTO issue_assignees(issue_id, user_id)
SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL
......
...@@ -495,7 +495,7 @@ ActiveRecord::Schema.define(version: 20170320173259) do ...@@ -495,7 +495,7 @@ ActiveRecord::Schema.define(version: 20170320173259) do
add_index "index_statuses", ["project_id"], name: "index_index_statuses_on_project_id", unique: true, using: :btree add_index "index_statuses", ["project_id"], name: "index_index_statuses_on_project_id", unique: true, using: :btree
create_table "issue_assignees", force: :cascade do |t| create_table "issue_assignees", id: false, force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", null: false
t.integer "issue_id", null: false t.integer "issue_id", null: false
end end
......
...@@ -68,14 +68,14 @@ Example response: ...@@ -68,14 +68,14 @@ Example response:
"updated_at" : "2016-01-04T15:31:39.996Z" "updated_at" : "2016-01-04T15:31:39.996Z"
}, },
"project_id" : 1, "project_id" : 1,
"assignee" : { "assignees" : [{
"state" : "active", "state" : "active",
"id" : 1, "id" : 1,
"name" : "Administrator", "name" : "Administrator",
"web_url" : "https://gitlab.example.com/root", "web_url" : "https://gitlab.example.com/root",
"avatar_url" : null, "avatar_url" : null,
"username" : "root" "username" : "root"
}, }],
"updated_at" : "2016-01-04T15:31:51.081Z", "updated_at" : "2016-01-04T15:31:51.081Z",
"id" : 76, "id" : 76,
"title" : "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.", "title" : "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.",
...@@ -150,14 +150,14 @@ Example response: ...@@ -150,14 +150,14 @@ Example response:
"description" : "Omnis vero earum sunt corporis dolor et placeat.", "description" : "Omnis vero earum sunt corporis dolor et placeat.",
"state" : "closed", "state" : "closed",
"iid" : 1, "iid" : 1,
"assignee" : { "assignees" : [{
"avatar_url" : null, "avatar_url" : null,
"web_url" : "https://gitlab.example.com/lennie", "web_url" : "https://gitlab.example.com/lennie",
"state" : "active", "state" : "active",
"username" : "lennie", "username" : "lennie",
"id" : 9, "id" : 9,
"name" : "Dr. Luella Kovacek" "name" : "Dr. Luella Kovacek"
}, }],
"labels" : [], "labels" : [],
"id" : 41, "id" : 41,
"title" : "Ut commodi ullam eos dolores perferendis nihil sunt.", "title" : "Ut commodi ullam eos dolores perferendis nihil sunt.",
...@@ -231,14 +231,14 @@ Example response: ...@@ -231,14 +231,14 @@ Example response:
"description" : "Omnis vero earum sunt corporis dolor et placeat.", "description" : "Omnis vero earum sunt corporis dolor et placeat.",
"state" : "closed", "state" : "closed",
"iid" : 1, "iid" : 1,
"assignee" : { "assignees" : [{
"avatar_url" : null, "avatar_url" : null,
"web_url" : "https://gitlab.example.com/lennie", "web_url" : "https://gitlab.example.com/lennie",
"state" : "active", "state" : "active",
"username" : "lennie", "username" : "lennie",
"id" : 9, "id" : 9,
"name" : "Dr. Luella Kovacek" "name" : "Dr. Luella Kovacek"
}, }],
"labels" : [], "labels" : [],
"id" : 41, "id" : 41,
"title" : "Ut commodi ullam eos dolores perferendis nihil sunt.", "title" : "Ut commodi ullam eos dolores perferendis nihil sunt.",
...@@ -297,14 +297,14 @@ Example response: ...@@ -297,14 +297,14 @@ Example response:
"description" : "Omnis vero earum sunt corporis dolor et placeat.", "description" : "Omnis vero earum sunt corporis dolor et placeat.",
"state" : "closed", "state" : "closed",
"iid" : 1, "iid" : 1,
"assignee" : { "assignees" : [{
"avatar_url" : null, "avatar_url" : null,
"web_url" : "https://gitlab.example.com/lennie", "web_url" : "https://gitlab.example.com/lennie",
"state" : "active", "state" : "active",
"username" : "lennie", "username" : "lennie",
"id" : 9, "id" : 9,
"name" : "Dr. Luella Kovacek" "name" : "Dr. Luella Kovacek"
}, }],
"labels" : [], "labels" : [],
"id" : 41, "id" : 41,
"title" : "Ut commodi ullam eos dolores perferendis nihil sunt.", "title" : "Ut commodi ullam eos dolores perferendis nihil sunt.",
...@@ -333,7 +333,7 @@ POST /projects/:id/issues ...@@ -333,7 +333,7 @@ POST /projects/:id/issues
| `title` | string | yes | The title of an issue | | `title` | string | yes | The title of an issue |
| `description` | string | no | The description of an issue | | `description` | string | no | The description of an issue |
| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | | `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. |
| `assignee_id` | integer | no | The ID of a user to assign issue | | `assignee_ids` | Array[integer] | no | The ID of a user to assign issue |
| `milestone_id` | integer | no | The ID of a milestone to assign issue | | `milestone_id` | integer | no | The ID of a milestone to assign issue |
| `labels` | string | no | Comma-separated label names for an issue | | `labels` | string | no | Comma-separated label names for an issue |
| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) |
...@@ -356,7 +356,7 @@ Example response: ...@@ -356,7 +356,7 @@ Example response:
"iid" : 14, "iid" : 14,
"title" : "Issues with auth", "title" : "Issues with auth",
"state" : "opened", "state" : "opened",
"assignee" : null, "assignees" : [],
"labels" : [ "labels" : [
"bug" "bug"
], ],
...@@ -396,7 +396,7 @@ PUT /projects/:id/issues/:issue_iid ...@@ -396,7 +396,7 @@ PUT /projects/:id/issues/:issue_iid
| `title` | string | no | The title of an issue | | `title` | string | no | The title of an issue |
| `description` | string | no | The description of an issue | | `description` | string | no | The description of an issue |
| `confidential` | boolean | no | Updates an issue to be confidential | | `confidential` | boolean | no | Updates an issue to be confidential |
| `assignee_id` | integer | no | The ID of a user to assign the issue to | | `assignee_ids` | Array[integer] | no | The ID of a user to assign the issue to |
| `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to |
| `labels` | string | no | Comma-separated label names for an issue | | `labels` | string | no | Comma-separated label names for an issue |
| `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it |
...@@ -431,7 +431,7 @@ Example response: ...@@ -431,7 +431,7 @@ Example response:
"bug" "bug"
], ],
"id" : 85, "id" : 85,
"assignee" : null, "assignees" : [],
"milestone" : null, "milestone" : null,
"subscribed" : true, "subscribed" : true,
"user_notes_count": 0, "user_notes_count": 0,
...@@ -496,14 +496,14 @@ Example response: ...@@ -496,14 +496,14 @@ Example response:
"updated_at": "2016-04-07T12:20:17.596Z", "updated_at": "2016-04-07T12:20:17.596Z",
"labels": [], "labels": [],
"milestone": null, "milestone": null,
"assignee": { "assignees": [{
"name": "Miss Monserrate Beier", "name": "Miss Monserrate Beier",
"username": "axel.block", "username": "axel.block",
"id": 12, "id": 12,
"state": "active", "state": "active",
"avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon",
"web_url": "https://gitlab.example.com/axel.block" "web_url": "https://gitlab.example.com/axel.block"
}, }],
"author": { "author": {
"name": "Kris Steuber", "name": "Kris Steuber",
"username": "solon.cremin", "username": "solon.cremin",
...@@ -552,14 +552,14 @@ Example response: ...@@ -552,14 +552,14 @@ Example response:
"updated_at": "2016-04-07T12:20:17.596Z", "updated_at": "2016-04-07T12:20:17.596Z",
"labels": [], "labels": [],
"milestone": null, "milestone": null,
"assignee": { "assignees": [{
"name": "Miss Monserrate Beier", "name": "Miss Monserrate Beier",
"username": "axel.block", "username": "axel.block",
"id": 12, "id": 12,
"state": "active", "state": "active",
"avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon",
"web_url": "https://gitlab.example.com/axel.block" "web_url": "https://gitlab.example.com/axel.block"
}, }],
"author": { "author": {
"name": "Kris Steuber", "name": "Kris Steuber",
"username": "solon.cremin", "username": "solon.cremin",
...@@ -656,14 +656,14 @@ Example response: ...@@ -656,14 +656,14 @@ Example response:
"updated_at": "2016-06-17T07:47:33.832Z", "updated_at": "2016-06-17T07:47:33.832Z",
"due_date": null "due_date": null
}, },
"assignee": { "assignees": [{
"name": "Jarret O'Keefe", "name": "Jarret O'Keefe",
"username": "francisca", "username": "francisca",
"id": 14, "id": 14,
"state": "active", "state": "active",
"avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon", "avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon",
"web_url": "https://gitlab.example.com/francisca" "web_url": "https://gitlab.example.com/francisca"
}, }],
"author": { "author": {
"name": "Maxie Medhurst", "name": "Maxie Medhurst",
"username": "craig_rutherford", "username": "craig_rutherford",
......
...@@ -232,7 +232,7 @@ X-Gitlab-Event: Issue Hook ...@@ -232,7 +232,7 @@ X-Gitlab-Event: Issue Hook
"object_attributes": { "object_attributes": {
"id": 301, "id": 301,
"title": "New API: create/update/delete file", "title": "New API: create/update/delete file",
"assignee_id": 51, "assignee_ids": [51],
"author_id": 51, "author_id": 51,
"project_id": 14, "project_id": 14,
"created_at": "2013-12-03T17:15:43Z", "created_at": "2013-12-03T17:15:43Z",
...@@ -246,11 +246,11 @@ X-Gitlab-Event: Issue Hook ...@@ -246,11 +246,11 @@ X-Gitlab-Event: Issue Hook
"url": "http://example.com/diaspora/issues/23", "url": "http://example.com/diaspora/issues/23",
"action": "open" "action": "open"
}, },
"assignee": { "assignees": [{
"name": "User1", "name": "User1",
"username": "user1", "username": "user1",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
}, }],
"labels": [{ "labels": [{
"id": 206, "id": 206,
"title": "API", "title": "API",
...@@ -544,7 +544,7 @@ X-Gitlab-Event: Note Hook ...@@ -544,7 +544,7 @@ X-Gitlab-Event: Note Hook
"issue": { "issue": {
"id": 92, "id": 92,
"title": "test", "title": "test",
"assignee_id": null, "assignee_ids": [],
"author_id": 1, "author_id": 1,
"project_id": 5, "project_id": 5,
"created_at": "2015-04-12 14:53:17 UTC", "created_at": "2015-04-12 14:53:17 UTC",
......
...@@ -295,6 +295,13 @@ module API ...@@ -295,6 +295,13 @@ module API
expose :project_id, :issues_events, :merge_requests_events expose :project_id, :issues_events, :merge_requests_events
expose :note_events, :build_events, :pipeline_events, :wiki_page_events expose :note_events, :build_events, :pipeline_events, :wiki_page_events
end end
class Issue < ::API::Entities::Issue
unexpose :assignees
expose :assignee do |issue, options|
::API::Entities::UserBasic.represent(issue.assignees.first, options)
end
end
end end
end end
end end
...@@ -14,6 +14,14 @@ module API ...@@ -14,6 +14,14 @@ module API
authorize! access_level, merge_request authorize! access_level, merge_request
merge_request merge_request
end end
def convert_parameters_from_legacy_format(params)
if params[:assignee_id].present?
params[:assignee_ids] = [params.delete(:assignee_id)]
end
params
end
end end
end end
end end
...@@ -8,6 +8,7 @@ module API ...@@ -8,6 +8,7 @@ module API
helpers do helpers do
def find_issues(args = {}) def find_issues(args = {})
args = params.merge(args) args = params.merge(args)
args = convert_parameters_from_legacy_format(args)
args.delete(:id) args.delete(:id)
args[:milestone_title] = args.delete(:milestone) args[:milestone_title] = args.delete(:milestone)
...@@ -53,7 +54,7 @@ module API ...@@ -53,7 +54,7 @@ module API
resource :issues do resource :issues do
desc "Get currently authenticated user's issues" do desc "Get currently authenticated user's issues" do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
optional :state, type: String, values: %w[opened closed all], default: 'all', optional :state, type: String, values: %w[opened closed all], default: 'all',
...@@ -62,7 +63,7 @@ module API ...@@ -62,7 +63,7 @@ module API
end end
get do get do
issues = find_issues(scope: 'authored') issues = find_issues(scope: 'authored')
present paginate(issues), with: ::API::Entities::Issue, current_user: current_user present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user
end end
end end
...@@ -71,7 +72,7 @@ module API ...@@ -71,7 +72,7 @@ module API
end end
resource :groups, requirements: { id: %r{[^/]+} } do resource :groups, requirements: { id: %r{[^/]+} } do
desc 'Get a list of group issues' do desc 'Get a list of group issues' do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
optional :state, type: String, values: %w[opened closed all], default: 'opened', optional :state, type: String, values: %w[opened closed all], default: 'opened',
...@@ -83,7 +84,7 @@ module API ...@@ -83,7 +84,7 @@ module API
issues = find_issues(group_id: group.id, state: params[:state] || 'opened', match_all_labels: true) issues = find_issues(group_id: group.id, state: params[:state] || 'opened', match_all_labels: true)
present paginate(issues), with: ::API::Entities::Issue, current_user: current_user present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user
end end
end end
...@@ -95,7 +96,7 @@ module API ...@@ -95,7 +96,7 @@ module API
desc 'Get a list of project issues' do desc 'Get a list of project issues' do
detail 'iid filter is deprecated have been removed on V4' detail 'iid filter is deprecated have been removed on V4'
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
optional :state, type: String, values: %w[opened closed all], default: 'all', optional :state, type: String, values: %w[opened closed all], default: 'all',
...@@ -108,22 +109,22 @@ module API ...@@ -108,22 +109,22 @@ module API
issues = find_issues(project_id: project.id) issues = find_issues(project_id: project.id)
present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
end end
desc 'Get a single project issue' do desc 'Get a single project issue' do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
requires :issue_id, type: Integer, desc: 'The ID of a project issue' requires :issue_id, type: Integer, desc: 'The ID of a project issue'
end end
get ":id/issues/:issue_id" do get ":id/issues/:issue_id" do
issue = find_project_issue(params[:issue_id]) issue = find_project_issue(params[:issue_id])
present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
end end
desc 'Create a new project issue' do desc 'Create a new project issue' do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
requires :title, type: String, desc: 'The title of an issue' requires :title, type: String, desc: 'The title of an issue'
...@@ -141,6 +142,7 @@ module API ...@@ -141,6 +142,7 @@ module API
issue_params = declared_params(include_missing: false) issue_params = declared_params(include_missing: false)
issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions)) issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions))
issue_params = convert_parameters_from_legacy_format(issue_params)
issue = ::Issues::CreateService.new(user_project, issue = ::Issues::CreateService.new(user_project,
current_user, current_user,
...@@ -148,14 +150,14 @@ module API ...@@ -148,14 +150,14 @@ module API
render_spam_error! if issue.spam? render_spam_error! if issue.spam?
if issue.valid? if issue.valid?
present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
else else
render_validation_error!(issue) render_validation_error!(issue)
end end
end end
desc 'Update an existing issue' do desc 'Update an existing issue' do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
requires :issue_id, type: Integer, desc: 'The ID of a project issue' requires :issue_id, type: Integer, desc: 'The ID of a project issue'
...@@ -178,6 +180,7 @@ module API ...@@ -178,6 +180,7 @@ module API
end end
update_params = declared_params(include_missing: false).merge(request: request, api: true) update_params = declared_params(include_missing: false).merge(request: request, api: true)
update_params = convert_parameters_from_legacy_format(update_params)
issue = ::Issues::UpdateService.new(user_project, issue = ::Issues::UpdateService.new(user_project,
current_user, current_user,
...@@ -186,14 +189,14 @@ module API ...@@ -186,14 +189,14 @@ module API
render_spam_error! if issue.spam? render_spam_error! if issue.spam?
if issue.valid? if issue.valid?
present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
else else
render_validation_error!(issue) render_validation_error!(issue)
end end
end end
desc 'Move an existing issue' do desc 'Move an existing issue' do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
requires :issue_id, type: Integer, desc: 'The ID of a project issue' requires :issue_id, type: Integer, desc: 'The ID of a project issue'
...@@ -208,7 +211,7 @@ module API ...@@ -208,7 +211,7 @@ module API
begin begin
issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project)
present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
rescue ::Issues::MoveService::MoveError => error rescue ::Issues::MoveService::MoveError => error
render_api_error!(error.message, 400) render_api_error!(error.message, 400)
end end
......
...@@ -32,7 +32,7 @@ module API ...@@ -32,7 +32,7 @@ module API
if project.has_external_issue_tracker? if project.has_external_issue_tracker?
::API::Entities::ExternalIssue ::API::Entities::ExternalIssue
else else
::API::Entities::Issue ::API::V3::Entities::Issue
end end
end end
......
...@@ -39,7 +39,7 @@ module API ...@@ -39,7 +39,7 @@ module API
end end
desc 'Get all issues for a single project milestone' do desc 'Get all issues for a single project milestone' do
success ::API::Entities::Issue success ::API::V3::Entities::Issue
end end
params do params do
requires :milestone_id, type: Integer, desc: 'The ID of a project milestone' requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
...@@ -56,7 +56,7 @@ module API ...@@ -56,7 +56,7 @@ module API
} }
issues = IssuesFinder.new(current_user, finder_params).execute issues = IssuesFinder.new(current_user, finder_params).execute
present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
end end
end end
end end
......
...@@ -1329,7 +1329,7 @@ describe Projects::MergeRequestsController do ...@@ -1329,7 +1329,7 @@ describe Projects::MergeRequestsController do
end end
it 'correctly pluralizes flash message on success' do it 'correctly pluralizes flash message on success' do
issue2.update!(assignees: [user]) issue2.assignees = [user]
post_assign_issues post_assign_issues
......
...@@ -41,7 +41,7 @@ describe "Dashboard Issues Feed", feature: true do ...@@ -41,7 +41,7 @@ describe "Dashboard Issues Feed", feature: true do
expect(entry).to be_present expect(entry).to be_present
expect(entry).to have_selector('author email', text: issue2.author_public_email) expect(entry).to have_selector('author email', text: issue2.author_public_email)
expect(entry).to have_selector('assignees email', text: issue2.assignees.first.public_email) expect(entry).to have_selector('assignees email', text: assignee.public_email)
expect(entry).not_to have_selector('labels') expect(entry).not_to have_selector('labels')
expect(entry).not_to have_selector('milestone') expect(entry).not_to have_selector('milestone')
expect(entry).to have_selector('description', text: issue2.description) expect(entry).to have_selector('description', text: issue2.description)
...@@ -64,7 +64,7 @@ describe "Dashboard Issues Feed", feature: true do ...@@ -64,7 +64,7 @@ describe "Dashboard Issues Feed", feature: true do
expect(entry).to be_present expect(entry).to be_present
expect(entry).to have_selector('author email', text: issue1.author_public_email) expect(entry).to have_selector('author email', text: issue1.author_public_email)
expect(entry).to have_selector('assignees email', text: issue1.assignees.first.public_email) expect(entry).to have_selector('assignees email', text: assignee.public_email)
expect(entry).to have_selector('labels label', text: label1.title) expect(entry).to have_selector('labels label', text: label1.title)
expect(entry).to have_selector('milestone', text: milestone1.title) expect(entry).to have_selector('milestone', text: milestone1.title)
expect(entry).not_to have_selector('description') expect(entry).not_to have_selector('description')
......
...@@ -22,7 +22,7 @@ describe 'Issues Feed', feature: true do ...@@ -22,7 +22,7 @@ describe 'Issues Feed', feature: true do
to have_content('application/atom+xml') to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('author email', text: issue.author_public_email) expect(body).to have_selector('author email', text: issue.author_public_email)
expect(body).to have_selector('assignee email', text: issue.author_public_email) expect(body).to have_selector('assignees email', text: issue.author_public_email)
expect(body).to have_selector('entry summary', text: issue.title) expect(body).to have_selector('entry summary', text: issue.title)
end end
end end
...@@ -36,7 +36,7 @@ describe 'Issues Feed', feature: true do ...@@ -36,7 +36,7 @@ describe 'Issues Feed', feature: true do
to have_content('application/atom+xml') to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('author email', text: issue.author_public_email) expect(body).to have_selector('author email', text: issue.author_public_email)
expect(body).to have_selector('assignee email', text: issue.author_public_email) expect(body).to have_selector('assignees email', text: issue.author_public_email)
expect(body).to have_selector('entry summary', text: issue.title) expect(body).to have_selector('entry summary', text: issue.title)
end end
end end
......
...@@ -131,7 +131,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -131,7 +131,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
context 'selecing issues' do context 'selecing issues' do
it 'selects single issue' do it 'selects single issue' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
page.within('.nav-links') do page.within('.nav-links') do
expect(page).to have_content('Selected issues 1') expect(page).to have_content('Selected issues 1')
...@@ -141,7 +141,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -141,7 +141,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'changes button text' do it 'changes button text' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
expect(first('.add-issues-footer .btn')).to have_content('Add 1 issue') expect(first('.add-issues-footer .btn')).to have_content('Add 1 issue')
end end
...@@ -149,7 +149,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -149,7 +149,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'changes button text with plural' do it 'changes button text with plural' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
all('.card').each do |el| all('.card .card-number').each do |el|
el.click el.click
end end
...@@ -159,7 +159,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -159,7 +159,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'shows only selected issues on selected tab' do it 'shows only selected issues on selected tab' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
click_link 'Selected issues' click_link 'Selected issues'
...@@ -189,7 +189,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -189,7 +189,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'selects all that arent already selected' do it 'selects all that arent already selected' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
expect(page).to have_selector('.is-active', count: 1) expect(page).to have_selector('.is-active', count: 1)
...@@ -201,11 +201,11 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -201,11 +201,11 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'unselects from selected tab' do it 'unselects from selected tab' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
click_link 'Selected issues' click_link 'Selected issues'
first('.card').click first('.card .card-number').click
expect(page).not_to have_selector('.is-active') expect(page).not_to have_selector('.is-active')
end end
...@@ -215,7 +215,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -215,7 +215,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
context 'adding issues' do context 'adding issues' do
it 'adds to board' do it 'adds to board' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
click_button 'Add 1 issue' click_button 'Add 1 issue'
end end
...@@ -227,7 +227,7 @@ describe 'Issue Boards add issue modal', :feature, :js do ...@@ -227,7 +227,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'adds to second list' do it 'adds to second list' do
page.within('.add-issues-modal') do page.within('.add-issues-modal') do
first('.card').click first('.card .card-number').click
click_button planning.title click_button planning.title
......
...@@ -80,6 +80,6 @@ describe 'Issues csv', feature: true do ...@@ -80,6 +80,6 @@ describe 'Issues csv', feature: true do
author: user, author: user,
milestone: milestone, milestone: milestone,
labels: [feature_label, idea_label]) labels: [feature_label, idea_label])
expect{ request_csv }.not_to exceed_query_limit(control_count + 23) expect{ request_csv }.not_to exceed_query_limit(control_count + 5)
end end
end end
...@@ -6,7 +6,7 @@ describe 'Unsubscribe links', feature: true do ...@@ -6,7 +6,7 @@ describe 'Unsubscribe links', feature: true do
let(:recipient) { create(:user) } let(:recipient) { create(:user) }
let(:author) { create(:user) } let(:author) { create(:user) }
let(:project) { create(:empty_project, :public) } let(:project) { create(:empty_project, :public) }
let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } } let(:params) { { title: 'A bug!', description: 'Fix it!', assignees: [recipient] } }
let(:issue) { Issues::CreateService.new(project, author, params).execute } let(:issue) { Issues::CreateService.new(project, author, params).execute }
let(:mail) { ActionMailer::Base.deliveries.last } let(:mail) { ActionMailer::Base.deliveries.last }
......
...@@ -31,13 +31,14 @@ describe Issue, elastic: true do ...@@ -31,13 +31,14 @@ describe Issue, elastic: true do
end end
it "returns json with all needed elements" do it "returns json with all needed elements" do
issue = create :issue, project: project assignee = create(:user)
issue = create :issue, project: project, assignees: [assignee]
expected_hash = issue.attributes.extract!('id', 'iid', 'title', 'description', 'created_at', expected_hash = issue.attributes.extract!('id', 'iid', 'title', 'description', 'created_at',
'updated_at', 'state', 'project_id', 'author_id', 'updated_at', 'state', 'project_id', 'author_id',
'confidential') 'confidential')
expected_hash['assignee_id'] = [] expected_hash['assignee_id'] = [assignee.id]
expect(issue.as_indexed_json).to eq(expected_hash) expect(issue.as_indexed_json).to eq(expected_hash)
end end
......
...@@ -56,84 +56,6 @@ describe Issue, "Issuable" do ...@@ -56,84 +56,6 @@ describe Issue, "Issuable" do
end end
end end
describe "before_save" do
describe "#update_cache_counts when an issue is reassigned" do
context "when previous assignee exists" do
before do
assignee = create(:user)
issue.project.team << [assignee, :developer]
issue.assignees << assignee
end
it "updates cache counts for new assignee" do
user = create(:user)
expect(user).to receive(:update_cache_counts)
issue.assignees << user
end
it "updates cache counts for previous assignee" do
old_assignee = issue.assignees.first
expect_any_instance_of(User).to receive(:update_cache_counts)
issue.assignees.destroy_all
end
end
context "when previous assignee does not exist" do
before{ issue.assignees = [] }
it "updates cache count for the new assignee" do
expect_any_instance_of(User).to receive(:update_cache_counts)
issue.assignees << user
end
end
end
describe "#update_cache_counts when a merge request is reassigned" do
let(:project) { create :project }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
context "when previous assignee exists" do
before do
assignee = create(:user)
project.team << [assignee, :developer]
merge_request.update(assignee: assignee)
end
it "updates cache counts for new assignee" do
user = create(:user)
expect(user).to receive(:update_cache_counts)
merge_request.update(assignee: user)
end
it "updates cache counts for previous assignee" do
old_assignee = merge_request.assignee
allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee)
expect(old_assignee).to receive(:update_cache_counts)
merge_request.update(assignee: nil)
end
end
context "when previous assignee does not exist" do
before { merge_request.update(assignee: nil) }
it "updates cache count for the new assignee" do
expect_any_instance_of(User).to receive(:update_cache_counts)
merge_request.update(assignee: user)
end
end
end
end
describe ".search" do describe ".search" do
let!(:searchable_issue) { create(:issue, title: "Searchable issue") } let!(:searchable_issue) { create(:issue, title: "Searchable issue") }
......
...@@ -38,6 +38,46 @@ describe Issue, models: true do ...@@ -38,6 +38,46 @@ describe Issue, models: true do
end end
end end
describe "before_save" do
describe "#update_cache_counts when an issue is reassigned" do
let(:issue) { create(:issue) }
let(:assignee) { create(:user) }
context "when previous assignee exists" do
before do
issue.project.team << [assignee, :developer]
issue.assignees << assignee
end
it "updates cache counts for new assignee" do
user = create(:user)
expect(user).to receive(:update_cache_counts)
issue.assignees << user
end
it "updates cache counts for previous assignee" do
issue.assignees.first
expect_any_instance_of(User).to receive(:update_cache_counts)
issue.assignees.destroy_all
end
end
context "when previous assignee does not exist" do
it "updates cache count for the new assignee" do
issue.assignees = []
expect_any_instance_of(User).to receive(:update_cache_counts)
issue.assignees << assignee
end
end
end
end
describe '#card_attributes' do describe '#card_attributes' do
it 'includes the author name' do it 'includes the author name' do
allow(subject).to receive(:author).and_return(double(name: 'Robert')) allow(subject).to receive(:author).and_return(double(name: 'Robert'))
......
...@@ -88,6 +88,48 @@ describe MergeRequest, models: true do ...@@ -88,6 +88,48 @@ describe MergeRequest, models: true do
end end
end end
describe "before_save" do
describe "#update_cache_counts when a merge request is reassigned" do
let(:project) { create :project }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:assignee) { create :user }
context "when previous assignee exists" do
before do
project.team << [assignee, :developer]
merge_request.update(assignee: assignee)
end
it "updates cache counts for new assignee" do
user = create(:user)
expect(user).to receive(:update_cache_counts)
merge_request.update(assignee: user)
end
it "updates cache counts for previous assignee" do
old_assignee = merge_request.assignee
allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee)
expect(old_assignee).to receive(:update_cache_counts)
merge_request.update(assignee: nil)
end
end
context "when previous assignee does not exist" do
it "updates cache count for the new assignee" do
merge_request.update(assignee: nil)
expect_any_instance_of(User).to receive(:update_cache_counts)
merge_request.update(assignee: assignee)
end
end
end
end
describe '#card_attributes' do describe '#card_attributes' do
it 'includes the author name' do it 'includes the author name' do
allow(subject).to receive(:author).and_return(double(name: 'Robert')) allow(subject).to receive(:author).and_return(double(name: 'Robert'))
......
...@@ -15,14 +15,14 @@ describe MergeRequests::AssignIssuesService, services: true do ...@@ -15,14 +15,14 @@ describe MergeRequests::AssignIssuesService, services: true do
expect(service.assignable_issues.map(&:id)).to include(issue.id) expect(service.assignable_issues.map(&:id)).to include(issue.id)
end end
it 'ignores issues already assigned to any user' do it 'ignores issues the user cannot update assignee on' do
issue.assignees = [create(:user)] project.team.truncate
expect(service.assignable_issues).to be_empty expect(service.assignable_issues).to be_empty
end end
it 'ignores issues the user cannot update assignee on' do it 'ignores issues already assigned to any user' do
project.team.truncate issue.assignees = [create(:user)]
expect(service.assignable_issues).to be_empty expect(service.assignable_issues).to be_empty
end end
......
...@@ -460,7 +460,7 @@ describe NotificationService, services: true do ...@@ -460,7 +460,7 @@ describe NotificationService, services: true do
it do it do
notification.new_issue(issue, @u_disabled) notification.new_issue(issue, @u_disabled)
should_email(issue.assignees.first) should_email(assignee)
should_email(@u_watcher) should_email(@u_watcher)
should_email(@u_guest_watcher) should_email(@u_guest_watcher)
should_email(@u_guest_custom) should_email(@u_guest_custom)
......
...@@ -3,6 +3,7 @@ require 'spec_helper' ...@@ -3,6 +3,7 @@ require 'spec_helper'
describe SlashCommands::InterpretService, services: true do describe SlashCommands::InterpretService, services: true do
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
let(:developer) { create(:user) } let(:developer) { create(:user) }
let(:developer2) { create(:user) }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
let(:milestone) { create(:milestone, project: project, title: '9.10') } let(:milestone) { create(:milestone, project: project, title: '9.10') }
let(:inprogress) { create(:label, project: project, title: 'In Progress') } let(:inprogress) { create(:label, project: project, title: 'In Progress') }
...@@ -388,6 +389,28 @@ describe SlashCommands::InterpretService, services: true do ...@@ -388,6 +389,28 @@ describe SlashCommands::InterpretService, services: true do
end end
end end
context 'assign command with multiple assignees' do
let(:content) { "/assign @#{developer.username} @#{developer2.username}" }
before{ project.team << [developer2, :developer] }
context 'Issue' do
it 'fetches assignee and populates assignee_id if content contains /assign' do
_, updates = service.execute(content, issue)
expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id])
end
end
context 'Merge Request' do
it 'fetches assignee and populates assignee_id if content contains /assign' do
_, updates = service.execute(content, merge_request)
expect(updates).to eq(assignee_id: developer.id)
end
end
end
it_behaves_like 'empty command' do it_behaves_like 'empty command' do
let(:content) { '/assign @abcd1234' } let(:content) { '/assign @abcd1234' }
let(:issuable) { issue } let(:issuable) { issue }
......
...@@ -63,7 +63,7 @@ shared_examples 'issuable record that supports slash commands in its description ...@@ -63,7 +63,7 @@ shared_examples 'issuable record that supports slash commands in its description
note = issuable.notes.user.first note = issuable.notes.user.first
expect(note.note).to eq "Awesome!" expect(note.note).to eq "Awesome!"
expect(issuable.assignee).to eq assignee expect(issuable.assignees).to eq [assignee]
expect(issuable.labels).to eq [label_bug] expect(issuable.labels).to eq [label_bug]
expect(issuable.milestone).to eq milestone expect(issuable.milestone).to eq milestone
end end
...@@ -81,7 +81,7 @@ shared_examples 'issuable record that supports slash commands in its description ...@@ -81,7 +81,7 @@ shared_examples 'issuable record that supports slash commands in its description
issuable.reload issuable.reload
expect(issuable.notes.user).to be_empty expect(issuable.notes.user).to be_empty
expect(issuable.assignee).to eq assignee expect(issuable.assignees).to eq [assignee]
expect(issuable.labels).to eq [label_bug] expect(issuable.labels).to eq [label_bug]
expect(issuable.milestone).to eq milestone expect(issuable.milestone).to eq milestone
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