Commit 2995f48e authored by Douwe Maan's avatar Douwe Maan

Merge branch 'orderable-issues' into 'master'

Allow issues in boards to be ordered

Closes #21264

See merge request !8916
parents 7f2819b7 8a199f32
...@@ -56,11 +56,6 @@ import boardCard from './board_card'; ...@@ -56,11 +56,6 @@ import boardCard from './board_card';
}); });
} }
}, },
computed: {
orderedIssues () {
return _.sortBy(this.issues, 'priority');
},
},
methods: { methods: {
listHeight () { listHeight () {
return this.$refs.list.getBoundingClientRect().height; return this.$refs.list.getBoundingClientRect().height;
...@@ -92,9 +87,9 @@ import boardCard from './board_card'; ...@@ -92,9 +87,9 @@ import boardCard from './board_card';
const options = gl.issueBoards.getBoardSortableDefaultOptions({ const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: document.querySelectorAll('.boards-list')[0], scroll: document.querySelectorAll('.boards-list')[0],
group: 'issues', group: 'issues',
sort: false,
disabled: this.disabled, disabled: this.disabled,
filter: '.board-list-count, .is-disabled', filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
onStart: (e) => { onStart: (e) => {
const card = this.$refs.issue[e.oldIndex]; const card = this.$refs.issue[e.oldIndex];
...@@ -111,6 +106,13 @@ import boardCard from './board_card'; ...@@ -111,6 +106,13 @@ import boardCard from './board_card';
e.item.remove(); e.item.remove();
}); });
}, },
onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
gl.issueBoards.BoardsStore.moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
}
}); });
this.sortable = Sortable.create(this.$refs.list, options); this.sortable = Sortable.create(this.$refs.list, options);
......
...@@ -15,6 +15,7 @@ class ListIssue { ...@@ -15,6 +15,7 @@ class ListIssue {
this.labels = []; this.labels = [];
this.selected = false; this.selected = false;
this.assignee = false; this.assignee = false;
this.position = obj.relative_position || Infinity;
if (obj.assignee) { if (obj.assignee) {
this.assignee = new ListUser(obj.assignee); this.assignee = new ListUser(obj.assignee);
...@@ -27,10 +28,6 @@ class ListIssue { ...@@ -27,10 +28,6 @@ class ListIssue {
obj.labels.forEach((label) => { obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label)); this.labels.push(new ListLabel(label));
}); });
this.priority = this.labels.reduce((max, label) => {
return (label.priority < max) ? label.priority : max;
}, Infinity);
} }
addLabel (label) { addLabel (label) {
......
...@@ -110,9 +110,20 @@ class List { ...@@ -110,9 +110,20 @@ class List {
} }
addIssue (issue, listFrom, newIndex) { addIssue (issue, listFrom, newIndex) {
let moveBeforeIid = null;
let moveAfterIid = null;
if (!this.findIssue(issue.id)) { if (!this.findIssue(issue.id)) {
if (newIndex !== undefined) { if (newIndex !== undefined) {
this.issues.splice(newIndex, 0, issue); this.issues.splice(newIndex, 0, issue);
if (this.issues[newIndex - 1]) {
moveBeforeIid = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex + 1]) {
moveAfterIid = this.issues[newIndex + 1].id;
}
} else { } else {
this.issues.push(issue); this.issues.push(issue);
} }
...@@ -123,13 +134,21 @@ class List { ...@@ -123,13 +134,21 @@ class List {
if (listFrom) { if (listFrom) {
this.issuesSize += 1; this.issuesSize += 1;
this.updateIssueLabel(issue, listFrom);
this.updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid);
}
} }
} }
moveIssue (issue, oldIndex, newIndex, moveBeforeIid, moveAfterIid) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid);
} }
updateIssueLabel(issue, listFrom) { updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id) gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid)
.then(() => { .then(() => {
listFrom.getIssues(false); listFrom.getIssues(false);
}); });
......
...@@ -64,10 +64,12 @@ class BoardService { ...@@ -64,10 +64,12 @@ class BoardService {
return this.issues.get(data); return this.issues.get(data);
} }
moveIssue (id, from_list_id, to_list_id) { moveIssue (id, from_list_id = null, to_list_id = null, move_before_iid = null, move_after_iid = null) {
return this.issue.update({ id }, { return this.issue.update({ id }, {
from_list_id, from_list_id,
to_list_id to_list_id,
move_before_iid,
move_after_iid,
}); });
} }
......
...@@ -109,6 +109,12 @@ ...@@ -109,6 +109,12 @@
listFrom.removeIssue(issue); listFrom.removeIssue(issue);
} }
}, },
moveIssueInList (list, issue, oldIndex, newIndex, idArray) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + 1], 10) || null;
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
findList (key, val, type = 'label') { findList (key, val, type = 'label') {
return this.state.lists.filter((list) => { return this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true; const byType = type ? list['type'] === type : true;
......
...@@ -43,7 +43,14 @@ ...@@ -43,7 +43,14 @@
return event; return event;
} }
function getTraget(target) { function isLast(target) {
var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
var children = el.children;
return children.length - 1 === target.index;
}
function getTarget(target) {
var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
var children = el.children; var children = el.children;
...@@ -75,12 +82,22 @@ ...@@ -75,12 +82,22 @@
function simulateDrag(options, callback) { function simulateDrag(options, callback) {
options.to.el = options.to.el || options.from.el; options.to.el = options.to.el || options.from.el;
var fromEl = getTraget(options.from); var fromEl = getTarget(options.from);
var toEl = getTraget(options.to); var toEl = getTarget(options.to);
var firstEl = getTarget({
el: options.to.el,
index: 'first'
});
var lastEl = getTarget({
el: options.to.el,
index: 'last'
});
var scrollable = options.scrollable; var scrollable = options.scrollable;
var fromRect = getRect(fromEl); var fromRect = getRect(fromEl);
var toRect = getRect(toEl); var toRect = getRect(toEl);
var firstRect = getRect(firstEl);
var lastRect = getRect(lastEl);
var startTime = new Date().getTime(); var startTime = new Date().getTime();
var duration = options.duration || 1000; var duration = options.duration || 1000;
...@@ -88,6 +105,12 @@ ...@@ -88,6 +105,12 @@
options.ontap && options.ontap(); options.ontap && options.ontap();
window.SIMULATE_DRAG_ACTIVE = 1; window.SIMULATE_DRAG_ACTIVE = 1;
if (options.to.index === 0) {
toRect.cy = firstRect.y;
} else if (isLast(options.to)) {
toRect.cy = lastRect.y + lastRect.h + 50;
}
var dragInterval = setInterval(function loop() { var dragInterval = setInterval(function loop() {
var progress = (new Date().getTime() - startTime) / duration; var progress = (new Date().getTime() - startTime) / duration;
var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft; var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft;
......
...@@ -8,6 +8,7 @@ module Projects ...@@ -8,6 +8,7 @@ module Projects
def index def index
issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
issues = issues.page(params[:page]).per(params[:per] || 20) issues = issues.page(params[:page]).per(params[:per] || 20)
make_sure_position_is_set(issues)
render json: { render json: {
issues: serialize_as_json(issues), issues: serialize_as_json(issues),
...@@ -38,6 +39,12 @@ module Projects ...@@ -38,6 +39,12 @@ module Projects
private private
def make_sure_position_is_set(issues)
issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position
end
end
def issue def issue
@issue ||= @issue ||=
IssuesFinder.new(current_user, project_id: project.id) IssuesFinder.new(current_user, project_id: project.id)
...@@ -63,7 +70,7 @@ module Projects ...@@ -63,7 +70,7 @@ module Projects
end end
def move_params def move_params
params.permit(:board_id, :id, :from_list_id, :to_list_id) params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_iid, :move_after_iid)
end end
def issue_params def issue_params
...@@ -73,7 +80,7 @@ module Projects ...@@ -73,7 +80,7 @@ module Projects
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json( resource.as_json(
labels: true, labels: true,
only: [:id, :iid, :title, :confidential, :due_date], only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: { include: {
assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] } milestone: { only: [:id, :title] }
......
module RelativePositioning
extend ActiveSupport::Concern
MIN_POSITION = 0
MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
included do
after_save :save_positionable_neighbours
end
def min_relative_position
self.class.in_projects(project.id).minimum(:relative_position)
end
def max_relative_position
self.class.in_projects(project.id).maximum(:relative_position)
end
def prev_relative_position
prev_pos = nil
if self.relative_position
prev_pos = self.class.
in_projects(project.id).
where('relative_position < ?', self.relative_position).
maximum(:relative_position)
end
prev_pos || MIN_POSITION
end
def next_relative_position
next_pos = nil
if self.relative_position
next_pos = self.class.
in_projects(project.id).
where('relative_position > ?', self.relative_position).
minimum(:relative_position)
end
next_pos || MAX_POSITION
end
def move_between(before, after)
return move_after(before) unless after
return move_before(after) unless before
pos_before = before.relative_position
pos_after = after.relative_position
if pos_after && (pos_before == pos_after)
self.relative_position = pos_before
before.move_before(self)
after.move_after(self)
@positionable_neighbours = [before, after]
else
self.relative_position = position_between(pos_before, pos_after)
end
end
def move_before(after)
self.relative_position = position_between(after.prev_relative_position, after.relative_position)
end
def move_after(before)
self.relative_position = position_between(before.relative_position, before.next_relative_position)
end
def move_to_end
self.relative_position = position_between(max_relative_position, MAX_POSITION)
end
private
# This method takes two integer values (positions) and
# calculates some random position between them. The range is huge as
# the maximum integer value is 2147483647. Ideally, the calculated value would be
# exactly between those terminating values, but this will introduce possibility of a race condition
# so two or more issues can get the same value, we want to avoid that and we also want to avoid
# using a lock here. If we have two issues with distance more than one thousand, we are OK.
# Given the huge range of possible values that integer can fit we shoud never face a problem.
def position_between(pos_before, pos_after)
pos_before ||= MIN_POSITION
pos_after ||= MAX_POSITION
pos_before, pos_after = [pos_before, pos_after].sort
rand(pos_before.next..pos_after.pred)
end
def save_positionable_neighbours
return unless @positionable_neighbours
status = @positionable_neighbours.all?(&:save)
@positionable_neighbours = nil
status
end
end
...@@ -7,6 +7,7 @@ class Issue < ActiveRecord::Base ...@@ -7,6 +7,7 @@ class Issue < ActiveRecord::Base
include Sortable include Sortable
include Spammable include Spammable
include FasterCacheKeys include FasterCacheKeys
include RelativePositioning
DueDateStruct = Struct.new(:title, :name).freeze DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
......
...@@ -5,7 +5,7 @@ module Boards ...@@ -5,7 +5,7 @@ module Boards
issues = IssuesFinder.new(current_user, filter_params).execute issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? issues = without_board_labels(issues) unless movable_list?
issues = with_list_label(issues) if movable_list? issues = with_list_label(issues) if movable_list?
issues issues.reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'))
end end
private private
...@@ -26,7 +26,6 @@ module Boards ...@@ -26,7 +26,6 @@ module Boards
def filter_params def filter_params
set_default_scope set_default_scope
set_default_sort
set_project set_project
set_state set_state
...@@ -37,10 +36,6 @@ module Boards ...@@ -37,10 +36,6 @@ module Boards
params[:scope] = 'all' params[:scope] = 'all'
end end
def set_default_sort
params[:sort] = 'priority'
end
def set_project def set_project
params[:project_id] = project.id params[:project_id] = project.id
end end
......
...@@ -3,7 +3,7 @@ module Boards ...@@ -3,7 +3,7 @@ module Boards
class MoveService < BaseService class MoveService < BaseService
def execute(issue) def execute(issue)
return false unless can?(current_user, :update_issue, issue) return false unless can?(current_user, :update_issue, issue)
return false unless valid_move? return false if issue_params.empty?
update_service.execute(issue) update_service.execute(issue)
end end
...@@ -14,7 +14,7 @@ module Boards ...@@ -14,7 +14,7 @@ module Boards
@board ||= project.boards.find(params[:board_id]) @board ||= project.boards.find(params[:board_id])
end end
def valid_move? def move_between_lists?
moving_from_list.present? && moving_to_list.present? && moving_from_list.present? && moving_to_list.present? &&
moving_from_list != moving_to_list moving_from_list != moving_to_list
end end
...@@ -32,11 +32,19 @@ module Boards ...@@ -32,11 +32,19 @@ module Boards
end end
def issue_params def issue_params
{ attrs = {}
if move_between_lists?
attrs.merge!(
add_label_ids: add_label_ids, add_label_ids: add_label_ids,
remove_label_ids: remove_label_ids, remove_label_ids: remove_label_ids,
state_event: issue_state state_event: issue_state,
} )
end
attrs[:move_between_iids] = move_between_iids if move_between_iids
attrs
end end
def issue_state def issue_state
...@@ -58,6 +66,12 @@ module Boards ...@@ -58,6 +66,12 @@ module Boards
Array(label_ids).compact Array(label_ids).compact
end end
def move_between_iids
return unless params[:move_after_iid] || params[:move_before_iid]
[params[:move_after_iid], params[:move_before_iid]]
end
end end
end end
end end
...@@ -211,7 +211,7 @@ class IssuableBaseService < BaseService ...@@ -211,7 +211,7 @@ class IssuableBaseService < BaseService
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
if params.present? if issuable.changed? || params.present?
issuable.assign_attributes(params.merge(updated_by: current_user)) issuable.assign_attributes(params.merge(updated_by: current_user))
before_update(issuable) before_update(issuable)
......
...@@ -13,6 +13,7 @@ module Issues ...@@ -13,6 +13,7 @@ module Issues
def before_create(issue) def before_create(issue)
spam_check(issue, current_user) spam_check(issue, current_user)
issue.move_to_end
end end
def after_create(issuable) def after_create(issuable)
......
...@@ -3,8 +3,8 @@ module Issues ...@@ -3,8 +3,8 @@ module Issues
include SpamCheckService include SpamCheckService
def execute(issue) def execute(issue)
handle_move_between_iids(issue)
filter_spam_check_params filter_spam_check_params
update(issue) update(issue)
end end
...@@ -37,11 +37,13 @@ module Issues ...@@ -37,11 +37,13 @@ module Issues
end end
added_labels = issue.labels - old_labels added_labels = issue.labels - old_labels
if added_labels.present? if added_labels.present?
notification_service.relabeled_issue(issue, added_labels, current_user) notification_service.relabeled_issue(issue, added_labels, current_user)
end end
added_mentions = issue.mentioned_users - old_mentioned_users added_mentions = issue.mentioned_users - old_mentioned_users
if added_mentions.present? if added_mentions.present?
notification_service.new_mentions_in_issue(issue, added_mentions, current_user) notification_service.new_mentions_in_issue(issue, added_mentions, current_user)
end end
...@@ -55,8 +57,24 @@ module Issues ...@@ -55,8 +57,24 @@ module Issues
Issues::CloseService Issues::CloseService
end end
def handle_move_between_iids(issue)
return unless params[:move_between_iids]
after_iid, before_iid = params.delete(:move_between_iids)
issue_before = get_issue_if_allowed(issue.project, before_iid) if before_iid
issue_after = get_issue_if_allowed(issue.project, after_iid) if after_iid
issue.move_between(issue_before, issue_after)
end
private private
def get_issue_if_allowed(project, iid)
issue = project.issues.find_by(iid: iid)
issue if can?(current_user, :update_issue, issue)
end
def create_confidentiality_note(issue) def create_confidentiality_note(issue)
SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user) SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user)
end end
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
"v-show" => "!loading", "v-show" => "!loading",
":data-board" => "list.id", ":data-board" => "list.id",
":class" => '{ "is-smaller": showIssueForm }' } ":class" => '{ "is-smaller": showIssueForm }' }
%board-card{ "v-for" => "(issue, index) in orderedIssues", %board-card{ "v-for" => "(issue, index) in issues",
"ref" => "issue", "ref" => "issue",
":index" => "index", ":index" => "index",
":list" => "list", ":list" => "list",
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
":root-path" => "rootPath", ":root-path" => "rootPath",
":disabled" => "disabled", ":disabled" => "disabled",
":key" => "issue.id" } ":key" => "issue.id" }
%li.board-list-count.text-center{ "v-if" => "showCount" } %li.board-list-count.text-center{ "v-if" => "showCount",
"data-issue-id" => "-1" }
= icon("spinner spin", "v-show" => "list.loadingMore" ) = icon("spinner spin", "v-show" => "list.loadingMore" )
%span{ "v-if" => "list.issues.length === list.issuesSize" } %span{ "v-if" => "list.issues.length === list.issuesSize" }
Showing all issues Showing all issues
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddRelativePositionToIssues < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
# existing transaction. When using "add_concurrent_index" make sure that this
# method is the _only_ method called in the migration, any other changes
# should go in a separate migration. This ensures that upon failure _only_ the
# index creation fails and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
disable_ddl_transaction!
def up
add_column :issues, :relative_position, :integer
add_concurrent_index :issues, :relative_position
end
def down
remove_column :issues, :relative_position
remove_index :issues, :relative_position if index_exists? :issues, :relative_position
end
end
...@@ -530,6 +530,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do ...@@ -530,6 +530,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do
t.text "title_html" t.text "title_html"
t.text "description_html" t.text "description_html"
t.integer "time_estimate" t.integer "time_estimate"
t.integer "relative_position"
end end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
...@@ -541,6 +542,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do ...@@ -541,6 +542,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do
add_index "issues", ["due_date"], name: "index_issues_on_due_date", using: :btree add_index "issues", ["due_date"], name: "index_issues_on_due_date", using: :btree
add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree
add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree
add_index "issues", ["relative_position"], name: "index_issues_on_relative_position", using: :btree
add_index "issues", ["state"], name: "index_issues_on_state", using: :btree add_index "issues", ["state"], name: "index_issues_on_state", using: :btree
add_index "issues", ["title"], name: "index_issues_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} add_index "issues", ["title"], name: "index_issues_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"}
......
...@@ -43,6 +43,7 @@ describe Projects::Boards::IssuesController do ...@@ -43,6 +43,7 @@ describe Projects::Boards::IssuesController do
expect(response).to match_response_schema('issues') expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2 expect(parsed_response.length).to eq 2
expect(development.issues.map(&:relative_position)).not_to include(nil)
end end
end end
......
...@@ -71,16 +71,16 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -71,16 +71,16 @@ describe 'Issue Boards', feature: true, js: true do
let!(:list1) { create(:list, board: board, label: planning, position: 0) } let!(:list1) { create(:list, board: board, label: planning, position: 0) }
let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:list2) { create(:list, board: board, label: development, position: 1) }
let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning]) } let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning]) } let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) }
let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning]) } let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) }
let!(:issue3) { create(:labeled_issue, project: project, labels: [planning]) } let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) }
let!(:issue4) { create(:labeled_issue, project: project, labels: [planning]) } let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) }
let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) } let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone, relative_position: 4) }
let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) } let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development], relative_position: 3) }
let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) } let!(:issue7) { create(:labeled_issue, project: project, labels: [development], relative_position: 2) }
let!(:issue8) { create(:closed_issue, project: project) } let!(:issue8) { create(:closed_issue, project: project) }
let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting]) } let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting], relative_position: 1) }
before do before do
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_board_path(project.namespace, project, board)
......
require 'rails_helper'
describe 'Issue Boards', :feature, :js do
include WaitForVueResource
include DragTo
let(:project) { create(:empty_project, :public) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project) }
let!(:list1) { create(:list, board: board, label: label, position: 0) }
let!(:issue1) { create(:labeled_issue, project: project, title: 'testing 1', labels: [label], relative_position: 3) }
let!(:issue2) { create(:labeled_issue, project: project, title: 'testing 2', labels: [label], relative_position: 2) }
let!(:issue3) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label], relative_position: 1) }
before do
project.team << [user, :master]
login_as(user)
end
context 'un-ordered issues' do
let!(:issue4) { create(:labeled_issue, project: project, labels: [label]) }
before do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_vue_resource
expect(page).to have_selector('.board', count: 2)
end
it 'has un-ordered issue as last issue' do
page.within(first('.board')) do
expect(all('.card').last).to have_content(issue4.title)
end
end
it 'moves un-ordered issue to top of list' do
drag(from_index: 3, to_index: 0)
page.within(first('.board')) do
expect(first('.card')).to have_content(issue4.title)
end
end
end
context 'ordering in list' do
before do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_vue_resource
expect(page).to have_selector('.board', count: 2)
end
it 'moves from middle to top' do
drag(from_index: 1, to_index: 0)
wait_for_vue_resource
expect(first('.card')).to have_content(issue2.title)
end
it 'moves from middle to bottom' do
drag(from_index: 1, to_index: 2)
wait_for_vue_resource
expect(all('.card').last).to have_content(issue2.title)
end
it 'moves from top to bottom' do
drag(from_index: 0, to_index: 2)
wait_for_vue_resource
expect(all('.card').last).to have_content(issue3.title)
end
it 'moves from bottom to top' do
drag(from_index: 2, to_index: 0)
wait_for_vue_resource
expect(first('.card')).to have_content(issue1.title)
end
it 'moves from top to middle' do
drag(from_index: 0, to_index: 1)
wait_for_vue_resource
expect(first('.card')).to have_content(issue2.title)
end
it 'moves from bottom to middle' do
drag(from_index: 2, to_index: 1)
wait_for_vue_resource
expect(all('.card').last).to have_content(issue2.title)
end
end
context 'ordering when changing list' do
let(:label2) { create(:label, project: project) }
let!(:list2) { create(:list, board: board, label: label2, position: 1) }
let!(:issue4) { create(:labeled_issue, project: project, title: 'testing 1', labels: [label2], relative_position: 3.0) }
let!(:issue5) { create(:labeled_issue, project: project, title: 'testing 2', labels: [label2], relative_position: 2.0) }
let!(:issue6) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label2], relative_position: 1.0) }
before do
visit namespace_project_board_path(project.namespace, project, board)
wait_for_vue_resource
expect(page).to have_selector('.board', count: 3)
end
it 'moves to top of another list' do
drag(list_from_index: 0, list_to_index: 1)
wait_for_vue_resource
expect(first('.board')).to have_selector('.card', count: 2)
expect(all('.board')[1]).to have_selector('.card', count: 4)
page.within(all('.board')[1]) do
expect(first('.card')).to have_content(issue3.title)
end
end
it 'moves to bottom of another list' do
drag(list_from_index: 0, list_to_index: 1, to_index: 2)
wait_for_vue_resource
expect(first('.board')).to have_selector('.card', count: 2)
expect(all('.board')[1]).to have_selector('.card', count: 4)
page.within(all('.board')[1]) do
expect(all('.card').last).to have_content(issue3.title)
end
end
it 'moves to index of another list' do
drag(list_from_index: 0, list_to_index: 1, to_index: 1)
wait_for_vue_resource
expect(first('.board')).to have_selector('.card', count: 2)
expect(all('.board')[1]).to have_selector('.card', count: 4)
page.within(all('.board')[1]) do
expect(all('.card')[1]).to have_content(issue3.title)
end
end
end
def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0)
drag_to(selector: selector,
scrollable: '#board-app',
list_from_index: list_from_index,
from_index: from_index,
to_index: to_index,
list_to_index: list_to_index)
end
end
...@@ -11,8 +11,8 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -11,8 +11,8 @@ describe 'Issue Boards', feature: true, js: true do
let!(:bug) { create(:label, project: project, name: 'Bug') } let!(:bug) { create(:label, project: project, name: 'Bug') }
let!(:regression) { create(:label, project: project, name: 'Regression') } let!(:regression) { create(:label, project: project, name: 'Regression') }
let!(:stretch) { create(:label, project: project, name: 'Stretch') } let!(:stretch) { create(:label, project: project, name: 'Stretch') }
let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development]) } let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) }
let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch]) } let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let!(:list) { create(:list, board: board, label: development, position: 0) } let!(:list) { create(:list, board: board, label: development, position: 0) }
let(:card) { first('.board').first('.card') } let(:card) { first('.board').first('.card') }
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"title": { "type": "string" }, "title": { "type": "string" },
"confidential": { "type": "boolean" }, "confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] }, "due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" },
"labels": { "labels": {
"type": "array", "type": "array",
"items": { "items": {
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
/* global Cookies */ /* global Cookies */
/* global listObj */ /* global listObj */
/* global listObjDuplicate */ /* global listObjDuplicate */
/* global ListIssue */
require('~/lib/utils/url_utility'); require('~/lib/utils/url_utility');
require('~/boards/models/issue'); require('~/boards/models/issue');
...@@ -14,6 +15,7 @@ require('~/boards/models/user'); ...@@ -14,6 +15,7 @@ require('~/boards/models/user');
require('~/boards/services/board_service'); require('~/boards/services/board_service');
require('~/boards/stores/boards_store'); require('~/boards/stores/boards_store');
require('./mock_data'); require('./mock_data');
require('es6-promise').polyfill();
describe('Store', () => { describe('Store', () => {
beforeEach(() => { beforeEach(() => {
...@@ -21,6 +23,10 @@ describe('Store', () => { ...@@ -21,6 +23,10 @@ describe('Store', () => {
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => {
resolve();
}));
Cookies.set('issue_board_welcome_hidden', 'false', { Cookies.set('issue_board_welcome_hidden', 'false', {
expires: 365 * 10, expires: 365 * 10,
path: '' path: ''
...@@ -154,5 +160,74 @@ describe('Store', () => { ...@@ -154,5 +160,74 @@ describe('Store', () => {
done(); done();
}, 0); }, 0);
}); });
it('moves issue to top of another list', (done) => {
const listOne = gl.issueBoards.BoardsStore.addList(listObj);
const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
setTimeout(() => {
listOne.issues[0].id = 2;
expect(listOne.issues.length).toBe(1);
expect(listTwo.issues.length).toBe(1);
gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 0);
expect(listOne.issues.length).toBe(0);
expect(listTwo.issues.length).toBe(2);
expect(listTwo.issues[0].id).toBe(2);
expect(gl.boardService.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, null, 1);
done();
}, 0);
});
it('moves issue to bottom of another list', (done) => {
const listOne = gl.issueBoards.BoardsStore.addList(listObj);
const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
setTimeout(() => {
listOne.issues[0].id = 2;
expect(listOne.issues.length).toBe(1);
expect(listTwo.issues.length).toBe(1);
gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 1);
expect(listOne.issues.length).toBe(0);
expect(listTwo.issues.length).toBe(2);
expect(listTwo.issues[1].id).toBe(2);
expect(gl.boardService.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, 1, null);
done();
}, 0);
});
it('moves issue in list', (done) => {
const issue = new ListIssue({
title: 'Testing',
iid: 2,
confidential: false,
labels: []
});
const list = gl.issueBoards.BoardsStore.addList(listObj);
setTimeout(() => {
list.addIssue(issue);
expect(list.issues.length).toBe(2);
gl.issueBoards.BoardsStore.moveIssueInList(list, issue, 0, 1, [1, 2]);
expect(list.issues[0].id).toBe(2);
expect(gl.boardService.moveIssue).toHaveBeenCalledWith(2, null, null, 1, null);
done();
});
});
}); });
}); });
...@@ -79,4 +79,20 @@ describe('Issue model', () => { ...@@ -79,4 +79,20 @@ describe('Issue model', () => {
issue.removeLabels([issue.labels[0], issue.labels[1]]); issue.removeLabels([issue.labels[0], issue.labels[1]]);
expect(issue.labels.length).toBe(0); expect(issue.labels.length).toBe(0);
}); });
it('sets position to infinity if no position is stored', () => {
expect(issue.position).toBe(Infinity);
});
it('sets position', () => {
const relativePositionIssue = new ListIssue({
title: 'Testing',
iid: 1,
confidential: false,
relative_position: 1,
labels: []
});
expect(relativePositionIssue.position).toBe(1);
});
}); });
...@@ -103,6 +103,7 @@ describe('List model', () => { ...@@ -103,6 +103,7 @@ describe('List model', () => {
listDup.updateIssueLabel(list, issue); listDup.updateIssueLabel(list, issue);
expect(gl.boardService.moveIssue).toHaveBeenCalledWith(issue.id, list.id, listDup.id); expect(gl.boardService.moveIssue)
.toHaveBeenCalledWith(issue.id, list.id, listDup.id, undefined, undefined);
}); });
}); });
...@@ -21,6 +21,7 @@ Issue: ...@@ -21,6 +21,7 @@ Issue:
- milestone_id - milestone_id
- weight - weight
- time_estimate - time_estimate
- relative_position
Event: Event:
- id - id
- target_type - target_type
......
require 'spec_helper'
describe Issue, 'RelativePositioning' do
let(:project) { create(:empty_project) }
let(:issue) { create(:issue, project: project) }
let(:issue1) { create(:issue, project: project) }
let(:new_issue) { create(:issue, project: project) }
before do
[issue, issue1].each do |issue|
issue.move_to_end && issue.save
end
end
describe '#min_relative_position' do
it 'returns maximum position' do
expect(issue.min_relative_position).to eq issue.relative_position
end
end
describe '#max_relative_position' do
it 'returns maximum position' do
expect(issue.max_relative_position).to eq issue1.relative_position
end
end
describe '#prev_relative_position' do
it 'returns previous position if there is an issue above' do
expect(issue1.prev_relative_position).to eq issue.relative_position
end
it 'returns minimum position if there is no issue above' do
expect(issue.prev_relative_position).to eq RelativePositioning::MIN_POSITION
end
end
describe '#next_relative_position' do
it 'returns next position if there is an issue below' do
expect(issue.next_relative_position).to eq issue1.relative_position
end
it 'returns next position if there is no issue below' do
expect(issue1.next_relative_position).to eq RelativePositioning::MAX_POSITION
end
end
describe '#move_before' do
it 'moves issue before' do
[issue1, issue].each(&:move_to_end)
issue.move_before(issue1)
expect(issue.relative_position).to be < issue1.relative_position
end
end
describe '#move_after' do
it 'moves issue after' do
[issue, issue1].each(&:move_to_end)
issue.move_after(issue1)
expect(issue.relative_position).to be > issue1.relative_position
end
end
describe '#move_to_end' do
it 'moves issue to the end' do
new_issue.move_to_end
expect(new_issue.relative_position).to be > issue1.relative_position
end
end
describe '#move_between' do
it 'positions issue between two other' do
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to be > issue.relative_position
expect(new_issue.relative_position).to be < issue1.relative_position
end
it 'positions issue between on top' do
new_issue.move_between(nil, issue)
expect(new_issue.relative_position).to be < issue.relative_position
end
it 'positions issue between to end' do
new_issue.move_between(issue1, nil)
expect(new_issue.relative_position).to be > issue1.relative_position
end
it 'positions issues even when after and before positions are the same' do
issue1.update relative_position: issue.relative_position
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to be > issue.relative_position
expect(issue.relative_position).to be < issue1.relative_position
end
end
end
...@@ -43,32 +43,6 @@ describe Boards::Issues::ListService, services: true do ...@@ -43,32 +43,6 @@ describe Boards::Issues::ListService, services: true do
described_class.new(project, user, params).execute described_class.new(project, user, params).execute
end end
context 'sets default order to priority' do
it 'returns opened issues when list id is missing' do
params = { board_id: board.id }
issues = described_class.new(project, user, params).execute
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns closed issues when listing issues from Done' do
params = { board_id: board.id, id: done.id }
issues = described_class.new(project, user, params).execute
expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1]
end
it 'returns opened issues that have label list applied when listing issues from a label list' do
params = { board_id: board.id, id: list1.id }
issues = described_class.new(project, user, params).execute
expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
end
end
context 'with list that does not belong to the board' do context 'with list that does not belong to the board' do
it 'raises an error' do it 'raises an error' do
list = create(:list) list = create(:list)
......
...@@ -79,6 +79,8 @@ describe Boards::Issues::MoveService, services: true do ...@@ -79,6 +79,8 @@ describe Boards::Issues::MoveService, services: true do
context 'when moving to same list' do context 'when moving to same list' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } } let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } }
it 'returns false' do it 'returns false' do
...@@ -90,6 +92,18 @@ describe Boards::Issues::MoveService, services: true do ...@@ -90,6 +92,18 @@ describe Boards::Issues::MoveService, services: true do
expect(issue.reload.labels).to contain_exactly(bug, development) expect(issue.reload.labels).to contain_exactly(bug, development)
end end
it 'sorts issues' do
[issue, issue1, issue2].each do |issue|
issue.move_to_end && issue.save!
end
params.merge!(move_after_iid: issue1.iid, move_before_iid: issue2.iid)
described_class.new(project, user, params).execute(issue)
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
end end
end end
end end
...@@ -58,6 +58,22 @@ describe Issues::UpdateService, services: true do ...@@ -58,6 +58,22 @@ describe Issues::UpdateService, services: true do
expect(issue.due_date).to eq Date.tomorrow expect(issue.due_date).to eq Date.tomorrow
end end
it 'sorts issues as specified by parameters' do
issue1 = create(:issue, project: project, assignee_id: user3.id)
issue2 = create(:issue, project: project, assignee_id: user3.id)
[issue, issue1, issue2].each do |issue|
issue.move_to_end
issue.save
end
opts[:move_between_iids] = [issue1.iid, issue2.iid]
update_issue(opts)
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
context 'when current user cannot admin issues in the project' do context 'when current user cannot admin issues in the project' do
let(:guest) { create(:user) } let(:guest) { create(:user) }
before do before do
......
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