Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
8251f0c7
Commit
8251f0c7
authored
Oct 11, 2019
by
Kushal Pandya
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'multi-select-move' into 'master'
Multi select drag/drop in Issue Board See merge request gitlab-org/gitlab!16317
parents
5c8a8409
e81ca7d6
Changes
18
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
811 additions
and
21 deletions
+811
-21
app/assets/javascripts/boards/components/board_card.vue
app/assets/javascripts/boards/components/board_card.vue
+17
-3
app/assets/javascripts/boards/components/board_list.vue
app/assets/javascripts/boards/components/board_list.vue
+205
-13
app/assets/javascripts/boards/constants.js
app/assets/javascripts/boards/constants.js
+11
-0
app/assets/javascripts/boards/index.js
app/assets/javascripts/boards/index.js
+16
-2
app/assets/javascripts/boards/models/list.js
app/assets/javascripts/boards/models/list.js
+92
-0
app/assets/javascripts/boards/services/board_service.js
app/assets/javascripts/boards/services/board_service.js
+10
-0
app/assets/javascripts/boards/stores/boards_store.js
app/assets/javascripts/boards/stores/boards_store.js
+147
-1
app/assets/javascripts/test_utils/index.js
app/assets/javascripts/test_utils/index.js
+2
-0
app/assets/stylesheets/pages/boards.scss
app/assets/stylesheets/pages/boards.scss
+6
-0
app/controllers/groups/boards_controller.rb
app/controllers/groups/boards_controller.rb
+3
-0
app/controllers/projects/boards_controller.rb
app/controllers/projects/boards_controller.rb
+3
-0
doc/user/project/img/issue_boards_multi_select.png
doc/user/project/img/issue_boards_multi_select.png
+0
-0
doc/user/project/issue_board.md
doc/user/project/issue_board.md
+12
-0
ee/changelogs/unreleased/multi-select-move.yml
ee/changelogs/unreleased/multi-select-move.yml
+5
-0
locale/gitlab.pot
locale/gitlab.pot
+9
-0
spec/features/boards/multi_select_spec.rb
spec/features/boards/multi_select_spec.rb
+129
-0
spec/javascripts/boards/board_card_spec.js
spec/javascripts/boards/board_card_spec.js
+12
-2
spec/javascripts/boards/boards_store_spec.js
spec/javascripts/boards/boards_store_spec.js
+132
-0
No files found.
app/assets/javascripts/boards/components/board_card.vue
View file @
8251f0c7
...
...
@@ -42,12 +42,19 @@ export default {
return
{
showDetail
:
false
,
detailIssue
:
boardsStore
.
detail
,
multiSelect
:
boardsStore
.
multiSelect
,
};
},
computed
:
{
issueDetailVisible
()
{
return
this
.
detailIssue
.
issue
&&
this
.
detailIssue
.
issue
.
id
===
this
.
issue
.
id
;
},
multiSelectVisible
()
{
return
this
.
multiSelect
.
list
.
findIndex
(
issue
=>
issue
.
id
===
this
.
issue
.
id
)
>
-
1
;
},
canMultiSelect
()
{
return
gon
.
features
&&
gon
.
features
.
multiSelectBoard
;
},
},
methods
:
{
mouseDown
()
{
...
...
@@ -58,14 +65,20 @@ export default {
},
showIssue
(
e
)
{
if
(
e
.
target
.
classList
.
contains
(
'
js-no-trigger
'
))
return
;
if
(
this
.
showDetail
)
{
this
.
showDetail
=
false
;
// If CMD or CTRL is clicked
const
isMultiSelect
=
this
.
canMultiSelect
&&
(
e
.
ctrlKey
||
e
.
metaKey
);
if
(
boardsStore
.
detail
.
issue
&&
boardsStore
.
detail
.
issue
.
id
===
this
.
issue
.
id
)
{
eventHub
.
$emit
(
'
clearDetailIssue
'
);
eventHub
.
$emit
(
'
clearDetailIssue
'
,
isMultiSelect
);
if
(
isMultiSelect
)
{
eventHub
.
$emit
(
'
newDetailIssue
'
,
this
.
issue
,
isMultiSelect
);
}
}
else
{
eventHub
.
$emit
(
'
newDetailIssue
'
,
this
.
issue
);
eventHub
.
$emit
(
'
newDetailIssue
'
,
this
.
issue
,
isMultiSelect
);
boardsStore
.
setListDetail
(
this
.
list
);
}
}
...
...
@@ -77,6 +90,7 @@ export default {
<
template
>
<li
:class=
"
{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled
&&
issue.id,
'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible,
...
...
app/assets/javascripts/boards/components/board_list.vue
View file @
8251f0c7
<
script
>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import
Sortable
from
'
sortablejs
'
;
import
{
Sortable
,
MultiDrag
}
from
'
sortablejs
'
;
import
{
GlLoadingIcon
}
from
'
@gitlab/ui
'
;
import
_
from
'
underscore
'
;
import
boardNewIssue
from
'
./board_new_issue.vue
'
;
import
boardCard
from
'
./board_card.vue
'
;
import
eventHub
from
'
../eventhub
'
;
import
boardsStore
from
'
../stores/boards_store
'
;
import
{
getBoardSortableDefaultOptions
,
sortableStart
}
from
'
../mixins/sortable_default_options
'
;
import
{
sprintf
,
__
}
from
'
~/locale
'
;
import
createFlash
from
'
~/flash
'
;
import
{
getBoardSortableDefaultOptions
,
sortableStart
,
sortableEnd
,
}
from
'
../mixins/sortable_default_options
'
;
if
(
gon
.
features
&&
gon
.
features
.
multiSelectBoard
)
{
Sortable
.
mount
(
new
MultiDrag
());
}
export
default
{
name
:
'
BoardList
'
,
...
...
@@ -54,6 +64,14 @@ export default {
showIssueForm
:
false
,
};
},
computed
:
{
paginatedIssueText
()
{
return
sprintf
(
__
(
'
Showing %{pageSize} of %{total} issues
'
),
{
pageSize
:
this
.
list
.
issues
.
length
,
total
:
this
.
list
.
issuesSize
,
});
},
},
watch
:
{
filters
:
{
handler
()
{
...
...
@@ -87,11 +105,20 @@ export default {
eventHub
.
$on
(
`scroll-board-list-
${
this
.
list
.
id
}
`
,
this
.
scrollToTop
);
},
mounted
()
{
const
multiSelectOpts
=
{};
if
(
gon
.
features
&&
gon
.
features
.
multiSelectBoard
)
{
multiSelectOpts
.
multiDrag
=
true
;
multiSelectOpts
.
selectedClass
=
'
js-multi-select
'
;
multiSelectOpts
.
animation
=
500
;
}
const
options
=
getBoardSortableDefaultOptions
({
scroll
:
true
,
disabled
:
this
.
disabled
,
filter
:
'
.board-list-count, .is-disabled
'
,
dataIdAttr
:
'
data-issue-id
'
,
removeCloneOnHide
:
false
,
...
multiSelectOpts
,
group
:
{
name
:
'
issues
'
,
/**
...
...
@@ -145,25 +172,66 @@ export default {
card
.
showDetail
=
false
;
const
{
list
}
=
card
;
const
issue
=
list
.
findIssue
(
Number
(
e
.
item
.
dataset
.
issueId
));
boardsStore
.
startMoving
(
list
,
issue
);
sortableStart
();
},
onAdd
:
e
=>
{
boardsStore
.
moveIssueToList
(
boardsStore
.
moving
.
list
,
this
.
list
,
boardsStore
.
moving
.
issue
,
e
.
newIndex
,
);
const
{
items
=
[],
newIndicies
=
[]
}
=
e
;
if
(
items
.
length
)
{
// Not using e.newIndex here instead taking a min of all
// the newIndicies. Basically we have to find that during
// a drop what is the index we're going to start putting
// all the dropped elements from.
const
newIndex
=
Math
.
min
(...
newIndicies
.
map
(
obj
=>
obj
.
index
).
filter
(
i
=>
i
!==
-
1
));
const
issues
=
items
.
map
(
item
=>
boardsStore
.
moving
.
list
.
findIssue
(
Number
(
item
.
dataset
.
issueId
)),
);
this
.
$nextTick
(()
=>
{
e
.
item
.
remove
();
});
boardsStore
.
moveMultipleIssuesToList
({
listFrom
:
boardsStore
.
moving
.
list
,
listTo
:
this
.
list
,
issues
,
newIndex
,
});
}
else
{
boardsStore
.
moveIssueToList
(
boardsStore
.
moving
.
list
,
this
.
list
,
boardsStore
.
moving
.
issue
,
e
.
newIndex
,
);
this
.
$nextTick
(()
=>
{
e
.
item
.
remove
();
});
}
},
onUpdate
:
e
=>
{
const
sortedArray
=
this
.
sortable
.
toArray
().
filter
(
id
=>
id
!==
'
-1
'
);
const
{
items
=
[],
newIndicies
=
[],
oldIndicies
=
[]
}
=
e
;
if
(
items
.
length
)
{
const
newIndex
=
Math
.
min
(...
newIndicies
.
map
(
obj
=>
obj
.
index
));
const
issues
=
items
.
map
(
item
=>
boardsStore
.
moving
.
list
.
findIssue
(
Number
(
item
.
dataset
.
issueId
)),
);
boardsStore
.
moveMultipleIssuesInList
({
list
:
this
.
list
,
issues
,
oldIndicies
:
oldIndicies
.
map
(
obj
=>
obj
.
index
),
newIndex
,
idArray
:
sortedArray
,
});
e
.
items
.
forEach
(
el
=>
{
Sortable
.
utils
.
deselect
(
el
);
});
boardsStore
.
clearMultiSelect
();
return
;
}
boardsStore
.
moveIssueInList
(
this
.
list
,
boardsStore
.
moving
.
issue
,
...
...
@@ -172,9 +240,133 @@ export default {
sortedArray
,
);
},
onEnd
:
e
=>
{
const
{
items
=
[],
clones
=
[],
to
}
=
e
;
// This is not a multi select operation
if
(
!
items
.
length
&&
!
clones
.
length
)
{
sortableEnd
();
return
;
}
let
toList
;
if
(
to
)
{
const
containerEl
=
to
.
closest
(
'
.js-board-list
'
);
toList
=
boardsStore
.
findList
(
'
id
'
,
Number
(
containerEl
.
dataset
.
board
));
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const
isSameList
=
toList
&&
toList
.
id
===
this
.
list
.
id
;
if
(
toList
&&
!
isSameList
&&
boardsStore
.
shouldRemoveIssue
(
this
.
list
,
toList
))
{
const
issues
=
items
.
map
(
item
=>
this
.
list
.
findIssue
(
Number
(
item
.
dataset
.
issueId
)));
if
(
_
.
compact
(
issues
).
length
&&
!
boardsStore
.
issuesAreContiguous
(
this
.
list
,
issues
))
{
const
indexes
=
[];
const
ids
=
this
.
list
.
issues
.
map
(
i
=>
i
.
id
);
issues
.
forEach
(
issue
=>
{
const
index
=
ids
.
indexOf
(
issue
.
id
);
if
(
index
>
-
1
)
{
indexes
.
push
(
index
);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const
sortedIndexes
=
indexes
.
sort
((
a
,
b
)
=>
(
a
<
b
?
1
:
-
1
));
sortedIndexes
.
forEach
(
i
=>
{
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout
(()
=>
{
this
.
list
.
issues
.
splice
(
i
,
1
);
},
0
);
});
}
}
if
(
!
toList
)
{
createFlash
(
__
(
'
Something went wrong while performing the action.
'
));
}
if
(
!
isSameList
)
{
boardsStore
.
clearMultiSelect
();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if
(
clones
&&
clones
.
length
)
{
clones
.
forEach
(
el
=>
el
.
classList
.
remove
(
'
multi-select
'
));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array
.
from
(
document
.
querySelectorAll
(
'
.js-multi-select
'
)
||
[]).
forEach
(
item
=>
{
Sortable
.
utils
.
deselect
(
item
);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this
.
$nextTick
(()
=>
{
if
(
items
&&
items
.
length
)
{
items
.
forEach
(
item
=>
{
item
.
remove
();
});
}
});
}
sortableEnd
();
},
onMove
(
e
)
{
return
!
e
.
related
.
classList
.
contains
(
'
board-list-count
'
);
},
onSelect
(
e
)
{
const
{
item
:
{
classList
},
}
=
e
;
if
(
classList
&&
classList
.
contains
(
'
js-multi-select
'
)
&&
!
classList
.
contains
(
'
multi-select
'
)
)
{
Sortable
.
utils
.
deselect
(
e
.
item
);
}
},
onDeselect
:
e
=>
{
const
{
item
:
{
dataset
,
classList
},
}
=
e
;
if
(
classList
&&
classList
.
contains
(
'
multi-select
'
)
&&
!
classList
.
contains
(
'
js-multi-select
'
)
)
{
const
issue
=
this
.
list
.
findIssue
(
Number
(
dataset
.
issueId
));
boardsStore
.
toggleMultiSelect
(
issue
);
}
},
});
this
.
sortable
=
Sortable
.
create
(
this
.
$refs
.
list
,
options
);
...
...
@@ -260,7 +452,7 @@ export default {
<li
v-if=
"showCount"
class=
"board-list-count text-center"
data-issue-id=
"-1"
>
<gl-loading-icon
v-show=
"list.loadingMore"
label=
"Loading more issues"
/>
<span
v-if=
"list.issues.length === list.issuesSize"
>
{{
__
(
'
Showing all issues
'
)
}}
</span>
<span
v-else
>
Showing
{{
list
.
issues
.
length
}}
of
{{
list
.
issuesSize
}}
issues
</span>
<span
v-else
>
{{
paginatedIssueText
}}
</span>
</li>
</ul>
</div>
...
...
app/assets/javascripts/boards/constants.js
0 → 100644
View file @
8251f0c7
export
const
ListType
=
{
assignee
:
'
assignee
'
,
milestone
:
'
milestone
'
,
backlog
:
'
backlog
'
,
closed
:
'
closed
'
,
label
:
'
label
'
,
};
export
default
{
ListType
,
};
app/assets/javascripts/boards/index.js
View file @
8251f0c7
...
...
@@ -146,7 +146,7 @@ export default () => {
updateTokens
()
{
this
.
filterManager
.
updateTokens
();
},
updateDetailIssue
(
newIssue
)
{
updateDetailIssue
(
newIssue
,
multiSelect
=
false
)
{
const
{
sidebarInfoEndpoint
}
=
newIssue
;
if
(
sidebarInfoEndpoint
&&
newIssue
.
subscribed
===
undefined
)
{
newIssue
.
setFetchingState
(
'
subscriptions
'
,
true
);
...
...
@@ -185,9 +185,23 @@ export default () => {
});
}
if
(
multiSelect
)
{
boardsStore
.
toggleMultiSelect
(
newIssue
);
if
(
boardsStore
.
detail
.
issue
)
{
boardsStore
.
clearDetailIssue
();
return
;
}
return
;
}
boardsStore
.
setIssueDetail
(
newIssue
);
},
clearDetailIssue
()
{
clearDetailIssue
(
multiSelect
=
false
)
{
if
(
multiSelect
)
{
boardsStore
.
clearMultiSelect
();
}
boardsStore
.
clearDetailIssue
();
},
toggleSubscription
(
id
)
{
...
...
app/assets/javascripts/boards/models/list.js
View file @
8251f0c7
...
...
@@ -5,6 +5,7 @@ import ListLabel from './label';
import
ListAssignee
from
'
./assignee
'
;
import
ListIssue
from
'
ee_else_ce/boards/models/issue
'
;
import
{
urlParamsToObject
}
from
'
~/lib/utils/common_utils
'
;
import
flash
from
'
~/flash
'
;
import
boardsStore
from
'
../stores/boards_store
'
;
import
ListMilestone
from
'
./milestone
'
;
...
...
@@ -176,6 +177,53 @@ class List {
});
}
addMultipleIssues
(
issues
,
listFrom
,
newIndex
)
{
let
moveBeforeId
=
null
;
let
moveAfterId
=
null
;
const
listHasIssues
=
issues
.
every
(
issue
=>
this
.
findIssue
(
issue
.
id
));
if
(
!
listHasIssues
)
{
if
(
newIndex
!==
undefined
)
{
if
(
this
.
issues
[
newIndex
-
1
])
{
moveBeforeId
=
this
.
issues
[
newIndex
-
1
].
id
;
}
if
(
this
.
issues
[
newIndex
])
{
moveAfterId
=
this
.
issues
[
newIndex
].
id
;
}
this
.
issues
.
splice
(
newIndex
,
0
,
...
issues
);
}
else
{
this
.
issues
.
push
(...
issues
);
}
if
(
this
.
label
)
{
issues
.
forEach
(
issue
=>
issue
.
addLabel
(
this
.
label
));
}
if
(
this
.
assignee
)
{
if
(
listFrom
&&
listFrom
.
type
===
'
assignee
'
)
{
issues
.
forEach
(
issue
=>
issue
.
removeAssignee
(
listFrom
.
assignee
));
}
issues
.
forEach
(
issue
=>
issue
.
addAssignee
(
this
.
assignee
));
}
if
(
IS_EE
&&
this
.
milestone
)
{
if
(
listFrom
&&
listFrom
.
type
===
'
milestone
'
)
{
issues
.
forEach
(
issue
=>
issue
.
removeMilestone
(
listFrom
.
milestone
));
}
issues
.
forEach
(
issue
=>
issue
.
addMilestone
(
this
.
milestone
));
}
if
(
listFrom
)
{
this
.
issuesSize
+=
issues
.
length
;
this
.
updateMultipleIssues
(
issues
,
listFrom
,
moveBeforeId
,
moveAfterId
);
}
}
}
addIssue
(
issue
,
listFrom
,
newIndex
)
{
let
moveBeforeId
=
null
;
let
moveAfterId
=
null
;
...
...
@@ -230,6 +278,23 @@ class List {
});
}
moveMultipleIssues
({
issues
,
oldIndicies
,
newIndex
,
moveBeforeId
,
moveAfterId
})
{
oldIndicies
.
reverse
().
forEach
(
index
=>
{
this
.
issues
.
splice
(
index
,
1
);
});
this
.
issues
.
splice
(
newIndex
,
0
,
...
issues
);
gl
.
boardService
.
moveMultipleIssues
({
ids
:
issues
.
map
(
issue
=>
issue
.
id
),
fromListId
:
null
,
toListId
:
null
,
moveBeforeId
,
moveAfterId
,
})
.
catch
(()
=>
flash
(
__
(
'
Something went wrong while moving issues.
'
)));
}
updateIssueLabel
(
issue
,
listFrom
,
moveBeforeId
,
moveAfterId
)
{
gl
.
boardService
.
moveIssue
(
issue
.
id
,
listFrom
.
id
,
this
.
id
,
moveBeforeId
,
moveAfterId
)
...
...
@@ -238,10 +303,37 @@ class List {
});
}
updateMultipleIssues
(
issues
,
listFrom
,
moveBeforeId
,
moveAfterId
)
{
gl
.
boardService
.
moveMultipleIssues
({
ids
:
issues
.
map
(
issue
=>
issue
.
id
),
fromListId
:
listFrom
.
id
,
toListId
:
this
.
id
,
moveBeforeId
,
moveAfterId
,
})
.
catch
(()
=>
flash
(
__
(
'
Something went wrong while moving issues.
'
)));
}
findIssue
(
id
)
{
return
this
.
issues
.
find
(
issue
=>
issue
.
id
===
id
);
}
removeMultipleIssues
(
removeIssues
)
{
const
ids
=
removeIssues
.
map
(
issue
=>
issue
.
id
);
this
.
issues
=
this
.
issues
.
filter
(
issue
=>
{
const
matchesRemove
=
ids
.
includes
(
issue
.
id
);
if
(
matchesRemove
)
{
this
.
issuesSize
-=
1
;
issue
.
removeLabel
(
this
.
label
);
}
return
!
matchesRemove
;
});
}
removeIssue
(
removeIssue
)
{
this
.
issues
=
this
.
issues
.
filter
(
issue
=>
{
const
matchesRemove
=
removeIssue
.
id
===
issue
.
id
;
...
...
app/assets/javascripts/boards/services/board_service.js
View file @
8251f0c7
...
...
@@ -48,6 +48,16 @@ export default class BoardService {
return
boardsStore
.
moveIssue
(
id
,
fromListId
,
toListId
,
moveBeforeId
,
moveAfterId
);
}
moveMultipleIssues
({
ids
,
fromListId
=
null
,
toListId
=
null
,
moveBeforeId
=
null
,
moveAfterId
=
null
,
})
{
return
boardsStore
.
moveMultipleIssues
({
ids
,
fromListId
,
toListId
,
moveBeforeId
,
moveAfterId
});
}
newIssue
(
id
,
issue
)
{
return
boardsStore
.
newIssue
(
id
,
issue
);
}
...
...
app/assets/javascripts/boards/stores/boards_store.js
View file @
8251f0c7
...
...
@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import
axios
from
'
~/lib/utils/axios_utils
'
;
import
{
mergeUrlParams
}
from
'
~/lib/utils/url_utility
'
;
import
eventHub
from
'
../eventhub
'
;
import
{
ListType
}
from
'
../constants
'
;
const
boardsStore
=
{
disabled
:
false
,
...
...
@@ -39,6 +40,7 @@ const boardsStore = {
issue
:
{},
list
:
{},
},
multiSelect
:
{
list
:
[]
},
setEndpoints
({
boardsEndpoint
,
listsEndpoint
,
bulkUpdatePath
,
boardId
,
recentBoardsEndpoint
})
{
const
listsEndpointGenerate
=
`
${
listsEndpoint
}
/generate.json`
;
...
...
@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint
:
`
${
recentBoardsEndpoint
}
.json`
,
};
},
create
()
{
this
.
state
.
lists
=
[];
this
.
filter
.
path
=
getUrlParamsArray
().
join
(
'
&
'
);
...
...
@@ -134,6 +135,107 @@ const boardsStore = {
Object
.
assign
(
this
.
moving
,
{
list
,
issue
});
},
moveMultipleIssuesToList
({
listFrom
,
listTo
,
issues
,
newIndex
})
{
const
issueTo
=
issues
.
map
(
issue
=>
listTo
.
findIssue
(
issue
.
id
));
const
issueLists
=
_
.
flatten
(
issues
.
map
(
issue
=>
issue
.
getLists
()));
const
listLabels
=
issueLists
.
map
(
list
=>
list
.
label
);
const
hasMoveableIssues
=
_
.
compact
(
issueTo
).
length
>
0
;
if
(
!
hasMoveableIssues
)
{
// Check if target list assignee is already present in this issue
if
(
listTo
.
type
===
ListType
.
assignee
&&
listFrom
.
type
===
ListType
.
assignee
&&
issues
.
some
(
issue
=>
issue
.
findAssignee
(
listTo
.
assignee
))
)
{
const
targetIssues
=
issues
.
map
(
issue
=>
listTo
.
findIssue
(
issue
.
id
));
targetIssues
.
forEach
(
targetIssue
=>
targetIssue
.
removeAssignee
(
listFrom
.
assignee
));
}
else
if
(
listTo
.
type
===
'
milestone
'
)
{
const
currentMilestones
=
issues
.
map
(
issue
=>
issue
.
milestone
);
const
currentLists
=
this
.
state
.
lists
.
filter
(
list
=>
list
.
type
===
'
milestone
'
&&
list
.
id
!==
listTo
.
id
)
.
filter
(
list
=>
list
.
issues
.
some
(
listIssue
=>
issues
.
some
(
issue
=>
listIssue
.
id
===
issue
.
id
)),
);
issues
.
forEach
(
issue
=>
{
currentMilestones
.
forEach
(
milestone
=>
{
issue
.
removeMilestone
(
milestone
);
});
});
issues
.
forEach
(
issue
=>
{
issue
.
addMilestone
(
listTo
.
milestone
);
});
currentLists
.
forEach
(
currentList
=>
{
issues
.
forEach
(
issue
=>
{
currentList
.
removeIssue
(
issue
);
});
});
listTo
.
addMultipleIssues
(
issues
,
listFrom
,
newIndex
);
}
else
{
// Add to new lists issues if it doesn't already exist
listTo
.
addMultipleIssues
(
issues
,
listFrom
,
newIndex
);
}
}
else
{
listTo
.
updateMultipleIssues
(
issues
,
listFrom
);
issues
.
forEach
(
issue
=>
{
issue
.
removeLabel
(
listFrom
.
label
);
});
}
if
(
listTo
.
type
===
ListType
.
closed
&&
listFrom
.
type
!==
ListType
.
backlog
)
{
issueLists
.
forEach
(
list
=>
{
issues
.
forEach
(
issue
=>
{
list
.
removeIssue
(
issue
);
});
});
issues
.
forEach
(
issue
=>
{
issue
.
removeLabels
(
listLabels
);
});
}
else
if
(
listTo
.
type
===
ListType
.
backlog
&&
listFrom
.
type
===
ListType
.
assignee
)
{
issues
.
forEach
(
issue
=>
{
issue
.
removeAssignee
(
listFrom
.
assignee
);
});
issueLists
.
forEach
(
list
=>
{
issues
.
forEach
(
issue
=>
{
list
.
removeIssue
(
issue
);
});
});
}
else
if
(
listTo
.
type
===
ListType
.
backlog
&&
listFrom
.
type
===
ListType
.
milestone
)
{
issues
.
forEach
(
issue
=>
{
issue
.
removeMilestone
(
listFrom
.
milestone
);
});
issueLists
.
forEach
(
list
=>
{
issues
.
forEach
(
issue
=>
{
list
.
removeIssue
(
issue
);
});
});
}
else
if
(
this
.
shouldRemoveIssue
(
listFrom
,
listTo
)
&&
this
.
issuesAreContiguous
(
listFrom
,
issues
)
)
{
listFrom
.
removeMultipleIssues
(
issues
);
}
},
issuesAreContiguous
(
list
,
issues
)
{
// When there's only 1 issue selected, we can return early.
if
(
issues
.
length
===
1
)
return
true
;
// Create list of ids for issues involved.
const
listIssueIds
=
list
.
issues
.
map
(
issue
=>
issue
.
id
);
const
movedIssueIds
=
issues
.
map
(
issue
=>
issue
.
id
);
// Check if moved issue IDs is sub-array
// of source list issue IDs (i.e. contiguous selection).
return
listIssueIds
.
join
(
'
|
'
).
includes
(
movedIssueIds
.
join
(
'
|
'
));
},
moveIssueToList
(
listFrom
,
listTo
,
issue
,
newIndex
)
{
const
issueTo
=
listTo
.
findIssue
(
issue
.
id
);
const
issueLists
=
issue
.
getLists
();
...
...
@@ -195,6 +297,17 @@ const boardsStore = {
list
.
moveIssue
(
issue
,
oldIndex
,
newIndex
,
beforeId
,
afterId
);
},
moveMultipleIssuesInList
({
list
,
issues
,
oldIndicies
,
newIndex
,
idArray
})
{
const
beforeId
=
parseInt
(
idArray
[
newIndex
-
1
],
10
)
||
null
;
const
afterId
=
parseInt
(
idArray
[
newIndex
+
issues
.
length
],
10
)
||
null
;
list
.
moveMultipleIssues
({
issues
,
oldIndicies
,
newIndex
,
moveBeforeId
:
beforeId
,
moveAfterId
:
afterId
,
});
},
findList
(
key
,
val
,
type
=
'
label
'
)
{
const
filteredList
=
this
.
state
.
lists
.
filter
(
list
=>
{
const
byType
=
type
...
...
@@ -260,6 +373,10 @@ const boardsStore = {
}
`
;
},
generateMultiDragPath
(
boardId
)
{
return
`
${
gon
.
relative_url_root
}
/-/boards/
${
boardId
?
`
${
boardId
}
`
:
''
}
/issues/bulk_move`
;
},
all
()
{
return
axios
.
get
(
this
.
state
.
endpoints
.
listsEndpoint
);
},
...
...
@@ -309,6 +426,16 @@ const boardsStore = {
});
},
moveMultipleIssues
({
ids
,
fromListId
,
toListId
,
moveBeforeId
,
moveAfterId
})
{
return
axios
.
put
(
this
.
generateMultiDragPath
(
this
.
state
.
endpoints
.
boardId
),
{
from_list_id
:
fromListId
,
to_list_id
:
toListId
,
move_before_id
:
moveBeforeId
,
move_after_id
:
moveAfterId
,
ids
,
});
},
newIssue
(
id
,
issue
)
{
return
axios
.
post
(
this
.
generateIssuesPath
(
id
),
{
issue
,
...
...
@@ -379,6 +506,25 @@ const boardsStore = {
setCurrentBoard
(
board
)
{
this
.
state
.
currentBoard
=
board
;
},
toggleMultiSelect
(
issue
)
{
const
selectedIssueIds
=
this
.
multiSelect
.
list
.
map
(
issue
=>
issue
.
id
);
const
index
=
selectedIssueIds
.
indexOf
(
issue
.
id
);
if
(
index
===
-
1
)
{
this
.
multiSelect
.
list
.
push
(
issue
);
return
;
}
this
.
multiSelect
.
list
=
[
...
this
.
multiSelect
.
list
.
slice
(
0
,
index
),
...
this
.
multiSelect
.
list
.
slice
(
index
+
1
),
];
},
clearMultiSelect
()
{
this
.
multiSelect
.
list
=
[];
},
};
BoardsStoreEE
.
initEESpecific
(
boardsStore
);
...
...
app/assets/javascripts/test_utils/index.js
View file @
8251f0c7
import
'
core-js/es/map
'
;
import
'
core-js/es/set
'
;
import
{
Sortable
}
from
'
sortablejs
'
;
import
simulateDrag
from
'
./simulate_drag
'
;
import
simulateInput
from
'
./simulate_input
'
;
// Export to global space for rspec to use
window
.
simulateDrag
=
simulateDrag
;
window
.
simulateInput
=
simulateInput
;
window
.
Sortable
=
Sortable
;
app/assets/stylesheets/pages/boards.scss
View file @
8251f0c7
...
...
@@ -245,6 +245,7 @@
box-shadow
:
0
1px
2px
$issue-boards-card-shadow
;
line-height
:
$gl-padding
;
list-style
:
none
;
position
:
relative
;
&
:not
(
:last-child
)
{
margin-bottom
:
$gl-padding-8
;
...
...
@@ -255,6 +256,11 @@
background-color
:
$blue-50
;
}
&
.multi-select
{
border-color
:
$blue-200
;
background-color
:
$blue-50
;
}
.badge
{
border
:
0
;
outline
:
0
;
...
...
app/controllers/groups/boards_controller.rb
View file @
8251f0c7
...
...
@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController
include
RecordUserLastActivity
before_action
:assign_endpoint_vars
before_action
do
push_frontend_feature_flag
(
:multi_select_board
)
end
private
...
...
app/controllers/projects/boards_controller.rb
View file @
8251f0c7
...
...
@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController
before_action
:check_issues_available!
before_action
:authorize_read_board!
,
only:
[
:index
,
:show
]
before_action
:assign_endpoint_vars
before_action
do
push_frontend_feature_flag
(
:multi_select_board
)
end
private
...
...
doc/user/project/img/issue_boards_multi_select.png
0 → 100644
View file @
8251f0c7
20.6 KB
doc/user/project/issue_board.md
View file @
8251f0c7
...
...
@@ -180,6 +180,18 @@ These are shortcuts to your last 4 visited boards.
When you're revisiting an issue board in a project or group with multiple boards,
GitLab will automatically load the last board you visited.
### Multi-select Issue Cards
As the name suggest, multi-select issue cards allows more than one issue card
to be dragged and dropped across different lists. This becomes helpful while
moving and grooming a lot of issues at once.
You can multi-select an issue card by pressing
`CTRL`
+
`Left mouse click`
on
Windows or
`CMD`
+
`Left mouse click`
on MacOS. Once done, start by dragging one
of the issue card you have selected and drop it in the new list you want.
![
Multi-select Issue Cards
](
img/issue_boards_multi_select.png
)
### Configurable Issue Boards **(STARTER)**
> Introduced in [GitLab Starter Edition 10.2](https://about.gitlab.com/2017/11/22/gitlab-10-2-released/#issue-boards-configuration).
...
...
ee/changelogs/unreleased/multi-select-move.yml
0 → 100644
View file @
8251f0c7
---
title
:
Add ability to multi select issue board cards
merge_request
:
16317
author
:
type
:
added
locale/gitlab.pot
View file @
8251f0c7
...
...
@@ -14842,6 +14842,9 @@ msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
msgid "Showing Latest Version"
msgstr ""
...
...
@@ -15097,6 +15100,12 @@ msgstr ""
msgid "Something went wrong while merging this merge request. Please try again."
msgstr ""
msgid "Something went wrong while moving issues."
msgstr ""
msgid "Something went wrong while performing the action."
msgstr ""
msgid "Something went wrong while reopening the %{issuable}. Please try again later"
msgstr ""
...
...
spec/features/boards/multi_select_spec.rb
0 → 100644
View file @
8251f0c7
# frozen_string_literal: true
require
'spec_helper'
describe
'Multi Select Issue'
,
:js
do
include
DragTo
let
(
:group
)
{
create
(
:group
,
:nested
)
}
let
(
:project
)
{
create
(
:project
,
:public
,
namespace:
group
)
}
let
(
:board
)
{
create
(
:board
,
project:
project
)
}
let
(
:user
)
{
create
(
:user
)
}
def
drag
(
selector:
'.board-list'
,
list_from_index:
1
,
from_index:
0
,
to_index:
0
,
list_to_index:
1
,
duration:
1000
)
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
,
duration:
duration
)
end
def
wait_for_board_cards
(
board_number
,
expected_cards
)
page
.
within
(
find
(
".board:nth-child(
#{
board_number
}
)"
))
do
expect
(
page
.
find
(
'.board-header'
)).
to
have_content
(
expected_cards
.
to_s
)
expect
(
page
).
to
have_selector
(
'.board-card'
,
count:
expected_cards
)
end
end
def
multi_select
(
selector
,
action
=
'select'
)
element
=
page
.
find
(
selector
)
script
=
"var el = document.querySelector('
#{
selector
}
');"
script
+=
"var mousedown = new MouseEvent('mousedown', { button: 0, bubbles: true });"
script
+=
"var mouseup = new MouseEvent('mouseup', { ctrlKey: true, button: 0, bubbles:true });"
script
+=
"el.dispatchEvent(mousedown); el.dispatchEvent(mouseup);"
script
+=
"Sortable.utils.
#{
action
}
(el);"
page
.
execute_script
(
script
,
element
)
end
before
do
project
.
add_maintainer
(
user
)
sign_in
(
user
)
end
context
'with lists'
do
let
(
:label1
)
{
create
(
:label
,
project:
project
,
name:
'Label 1'
,
description:
'Test'
)
}
let
(
:label2
)
{
create
(
:label
,
project:
project
,
name:
'Label 2'
,
description:
'Test'
)
}
let!
(
:list1
)
{
create
(
:list
,
board:
board
,
label:
label1
,
position:
0
)
}
let!
(
:list2
)
{
create
(
:list
,
board:
board
,
label:
label2
,
position:
1
)
}
let!
(
:issue1
)
{
create
(
:labeled_issue
,
project:
project
,
title:
'Issue 1'
,
description:
''
,
assignees:
[
user
],
labels:
[
label1
],
relative_position:
1
)
}
let!
(
:issue2
)
{
create
(
:labeled_issue
,
project:
project
,
title:
'Issue 2'
,
description:
''
,
author:
user
,
labels:
[
label1
],
relative_position:
2
)
}
let!
(
:issue3
)
{
create
(
:labeled_issue
,
project:
project
,
title:
'Issue 3'
,
description:
''
,
labels:
[
label1
],
relative_position:
3
)
}
let!
(
:issue4
)
{
create
(
:labeled_issue
,
project:
project
,
title:
'Issue 4'
,
description:
''
,
labels:
[
label1
],
relative_position:
4
)
}
let!
(
:issue5
)
{
create
(
:labeled_issue
,
project:
project
,
title:
'Issue 5'
,
description:
''
,
labels:
[
label1
],
relative_position:
5
)
}
before
do
visit
project_board_path
(
project
,
board
)
wait_for_requests
end
it
'moves multiple issues to another list'
,
:js
do
multi_select
(
'.board-card:nth-child(1)'
)
multi_select
(
'.board-card:nth-child(2)'
)
drag
(
list_from_index:
1
,
list_to_index:
2
)
wait_for_requests
page
.
within
(
all
(
'.js-board-list'
)[
2
])
do
expect
(
find
(
'.board-card:nth-child(1)'
)).
to
have_content
(
issue1
.
title
)
expect
(
find
(
'.board-card:nth-child(2)'
)).
to
have_content
(
issue2
.
title
)
end
end
it
'maintains order when moved'
,
:js
do
multi_select
(
'.board-card:nth-child(3)'
)
multi_select
(
'.board-card:nth-child(2)'
)
multi_select
(
'.board-card:nth-child(1)'
)
drag
(
list_from_index:
1
,
list_to_index:
2
)
wait_for_requests
page
.
within
(
all
(
'.js-board-list'
)[
2
])
do
expect
(
find
(
'.board-card:nth-child(1)'
)).
to
have_content
(
issue1
.
title
)
expect
(
find
(
'.board-card:nth-child(2)'
)).
to
have_content
(
issue2
.
title
)
expect
(
find
(
'.board-card:nth-child(3)'
)).
to
have_content
(
issue3
.
title
)
end
end
it
'move issues in the same list'
,
:js
do
multi_select
(
'.board-card:nth-child(3)'
)
multi_select
(
'.board-card:nth-child(4)'
)
drag
(
list_from_index:
1
,
list_to_index:
1
,
from_index:
2
,
to_index:
4
)
wait_for_requests
page
.
within
(
all
(
'.js-board-list'
)[
1
])
do
expect
(
find
(
'.board-card:nth-child(1)'
)).
to
have_content
(
issue1
.
title
)
expect
(
find
(
'.board-card:nth-child(2)'
)).
to
have_content
(
issue2
.
title
)
expect
(
find
(
'.board-card:nth-child(3)'
)).
to
have_content
(
issue5
.
title
)
expect
(
find
(
'.board-card:nth-child(4)'
)).
to
have_content
(
issue3
.
title
)
expect
(
find
(
'.board-card:nth-child(5)'
)).
to
have_content
(
issue4
.
title
)
end
end
it
'adds label when issues are moved to different card'
,
:js
do
page
.
within
(
all
(
'.js-board-list'
)[
1
])
do
expect
(
find
(
'.board-card:nth-child(1)'
)).
not_to
have_content
(
label2
.
title
)
expect
(
find
(
'.board-card:nth-child(2)'
)).
not_to
have_content
(
label2
.
title
)
end
multi_select
(
'.board-card:nth-child(1)'
)
multi_select
(
'.board-card:nth-child(2)'
)
drag
(
list_from_index:
1
,
list_to_index:
2
)
wait_for_requests
page
.
within
(
all
(
'.js-board-list'
)[
2
])
do
expect
(
find
(
'.board-card:nth-child(1)'
)).
to
have_content
(
label2
.
title
)
expect
(
find
(
'.board-card:nth-child(2)'
)).
to
have_content
(
label2
.
title
)
end
end
end
end
spec/javascripts/boards/board_card_spec.js
View file @
8251f0c7
...
...
@@ -67,6 +67,16 @@ describe('Board card', () => {
expect
(
vm
.
issueDetailVisible
).
toBe
(
true
);
});
it
(
"
returns false when multiSelect doesn't contain issue
"
,
()
=>
{
expect
(
vm
.
multiSelectVisible
).
toBe
(
false
);
});
it
(
'
returns true when multiSelect contains issue
'
,
()
=>
{
boardsStore
.
multiSelect
.
list
=
[
vm
.
issue
];
expect
(
vm
.
multiSelectVisible
).
toBe
(
true
);
});
it
(
'
adds user-can-drag class if not disabled
'
,
()
=>
{
expect
(
vm
.
$el
.
classList
.
contains
(
'
user-can-drag
'
)).
toBe
(
true
);
});
...
...
@@ -180,7 +190,7 @@ describe('Board card', () => {
triggerEvent
(
'
mousedown
'
);
triggerEvent
(
'
mouseup
'
);
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
newDetailIssue
'
,
vm
.
issue
);
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
newDetailIssue
'
,
vm
.
issue
,
undefined
);
expect
(
boardsStore
.
detail
.
list
).
toEqual
(
vm
.
list
);
});
...
...
@@ -203,7 +213,7 @@ describe('Board card', () => {
triggerEvent
(
'
mousedown
'
);
triggerEvent
(
'
mouseup
'
);
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
clearDetailIssue
'
);
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
clearDetailIssue
'
,
undefined
);
});
});
});
spec/javascripts/boards/boards_store_spec.js
View file @
8251f0c7
...
...
@@ -12,6 +12,7 @@ import '~/boards/services/board_service';
import
boardsStore
from
'
~/boards/stores/boards_store
'
;
import
eventHub
from
'
~/boards/eventhub
'
;
import
{
listObj
,
listObjDuplicate
,
boardsMockInterceptor
,
mockBoardService
}
from
'
./mock_data
'
;
import
waitForPromises
from
'
../../frontend/helpers/wait_for_promises
'
;
describe
(
'
Store
'
,
()
=>
{
let
mock
;
...
...
@@ -29,6 +30,13 @@ describe('Store', () => {
}),
);
spyOn
(
gl
.
boardService
,
'
moveMultipleIssues
'
).
and
.
callFake
(
()
=>
new
Promise
(
resolve
=>
{
resolve
();
}),
);
Cookies
.
set
(
'
issue_board_welcome_hidden
'
,
'
false
'
,
{
expires
:
365
*
10
,
path
:
''
,
...
...
@@ -376,4 +384,128 @@ describe('Store', () => {
expect
(
state
.
currentBoard
).
toEqual
(
dummyBoard
);
});
});
describe
(
'
toggleMultiSelect
'
,
()
=>
{
let
basicIssueObj
;
beforeAll
(()
=>
{
basicIssueObj
=
{
id
:
987654
};
});
afterEach
(()
=>
{
boardsStore
.
clearMultiSelect
();
});
it
(
'
adds issue when not present
'
,
()
=>
{
boardsStore
.
toggleMultiSelect
(
basicIssueObj
);
const
selectedIds
=
boardsStore
.
multiSelect
.
list
.
map
(
x
=>
x
.
id
);
expect
(
selectedIds
.
includes
(
basicIssueObj
.
id
)).
toEqual
(
true
);
});
it
(
'
removes issue when issue is present
'
,
()
=>
{
boardsStore
.
toggleMultiSelect
(
basicIssueObj
);
let
selectedIds
=
boardsStore
.
multiSelect
.
list
.
map
(
x
=>
x
.
id
);
expect
(
selectedIds
.
includes
(
basicIssueObj
.
id
)).
toEqual
(
true
);
boardsStore
.
toggleMultiSelect
(
basicIssueObj
);
selectedIds
=
boardsStore
.
multiSelect
.
list
.
map
(
x
=>
x
.
id
);
expect
(
selectedIds
.
includes
(
basicIssueObj
.
id
)).
toEqual
(
false
);
});
});
describe
(
'
clearMultiSelect
'
,
()
=>
{
it
(
'
clears all the multi selected issues
'
,
()
=>
{
const
issue1
=
{
id
:
12345
};
const
issue2
=
{
id
:
12346
};
boardsStore
.
toggleMultiSelect
(
issue1
);
boardsStore
.
toggleMultiSelect
(
issue2
);
expect
(
boardsStore
.
multiSelect
.
list
.
length
).
toEqual
(
2
);
boardsStore
.
clearMultiSelect
();
expect
(
boardsStore
.
multiSelect
.
list
.
length
).
toEqual
(
0
);
});
});
describe
(
'
moveMultipleIssuesToList
'
,
()
=>
{
it
(
'
move issues on the new index
'
,
done
=>
{
const
listOne
=
boardsStore
.
addList
(
listObj
);
const
listTwo
=
boardsStore
.
addList
(
listObjDuplicate
);
expect
(
boardsStore
.
state
.
lists
.
length
).
toBe
(
2
);
setTimeout
(()
=>
{
expect
(
listOne
.
issues
.
length
).
toBe
(
1
);
expect
(
listTwo
.
issues
.
length
).
toBe
(
1
);
boardsStore
.
moveMultipleIssuesToList
({
listFrom
:
listOne
,
listTo
:
listTwo
,
issues
:
listOne
.
issues
,
newIndex
:
0
,
});
expect
(
listTwo
.
issues
.
length
).
toBe
(
1
);
done
();
},
0
);
});
});
describe
(
'
moveMultipleIssuesInList
'
,
()
=>
{
it
(
'
moves multiple issues in list
'
,
done
=>
{
const
issueObj
=
{
title
:
'
Issue #1
'
,
id
:
12345
,
iid
:
2
,
confidential
:
false
,
labels
:
[],
assignees
:
[],
};
const
issue1
=
new
ListIssue
(
issueObj
);
const
issue2
=
new
ListIssue
({
...
issueObj
,
title
:
'
Issue #2
'
,
id
:
12346
,
});
const
list
=
boardsStore
.
addList
(
listObj
);
waitForPromises
()
.
then
(()
=>
{
list
.
addIssue
(
issue1
);
list
.
addIssue
(
issue2
);
expect
(
list
.
issues
.
length
).
toBe
(
3
);
expect
(
list
.
issues
[
0
].
id
).
not
.
toBe
(
issue2
.
id
);
boardsStore
.
moveMultipleIssuesInList
({
list
,
issues
:
[
issue1
,
issue2
],
oldIndicies
:
[
0
],
newIndex
:
1
,
idArray
:
[
1
,
12345
,
12346
],
});
expect
(
list
.
issues
[
0
].
id
).
toBe
(
issue1
.
id
);
expect
(
gl
.
boardService
.
moveMultipleIssues
).
toHaveBeenCalledWith
({
ids
:
[
issue1
.
id
,
issue2
.
id
],
fromListId
:
null
,
toListId
:
null
,
moveBeforeId
:
1
,
moveAfterId
:
null
,
});
done
();
})
.
catch
(
done
.
fail
);
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment