summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDouwe Maan <douwe@gitlab.com>2016-08-17 18:02:26 +0000
committerRuben Davila <rdavila84@gmail.com>2016-08-17 14:55:24 -0500
commita5b5061ee61d16b6d01e1a1ff9c7817be1b866c3 (patch)
treeb3d9f4c13602cb666db1271285c9b581541e46f3
parent4b0d5a9ce1893e9de486ec8245ce4953de71aa01 (diff)
downloadgitlab-ce-a5b5061ee61d16b6d01e1a1ff9c7817be1b866c3.tar.gz
Merge branch 'issue-boards' into 'master'
Issue boards - Issue: #17907 - Issue backend: #20335 - Backend MR: !5548 - Frontend MR: !5554 - Documentation !5713 - [x] [CHANGELOG](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CHANGELOG) entry added - [X] ~~[Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md)~~ - [X] ~~API support added~~ - Tests - [x] Added for this feature/bug - [x] All builds are passing - [x] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides) - [x] Branch has no merge conflicts with `master` (if you do - rebase it please) - [ ] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits) See merge request !5548
-rw-r--r--CHANGELOG1
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js.es657
-rw-r--r--app/assets/javascripts/boards/components/board.js.es685
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js.es649
-rw-r--r--app/assets/javascripts/boards/components/board_card.js.es643
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js.es619
-rw-r--r--app/assets/javascripts/boards/components/board_list.js.es689
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js.es654
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js.es625
-rw-r--r--app/assets/javascripts/boards/models/issue.js.es644
-rw-r--r--app/assets/javascripts/boards/models/label.js.es69
-rw-r--r--app/assets/javascripts/boards/models/list.js.es6125
-rw-r--r--app/assets/javascripts/boards/models/user.js.es68
-rw-r--r--app/assets/javascripts/boards/services/board_service.js.es661
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js.es6112
-rwxr-xr-xapp/assets/javascripts/boards/test_utils/simulate_drag.js119
-rw-r--r--app/assets/javascripts/boards/vue_resource_interceptor.js.es68
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js1
-rw-r--r--app/assets/javascripts/create_label.js.es6126
-rw-r--r--app/assets/javascripts/labels_select.js88
-rw-r--r--app/assets/javascripts/milestone_select.js8
-rw-r--r--app/assets/javascripts/users_select.js9
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/pages/boards.scss329
-rw-r--r--app/controllers/projects/board_lists_controller.rb65
-rw-r--r--app/controllers/projects/boards/application_controller.rb15
-rw-r--r--app/controllers/projects/boards/issues_controller.rb56
-rw-r--r--app/controllers/projects/boards/lists_controller.rb81
-rw-r--r--app/controllers/projects/boards_controller.rb15
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/models/ability.rb5
-rw-r--r--app/models/board.rb7
-rw-r--r--app/models/label.rb2
-rw-r--r--app/models/list.rb34
-rw-r--r--app/models/project.rb2
-rw-r--r--app/services/boards/base_service.rb5
-rw-r--r--app/services/boards/create_service.rb16
-rw-r--r--app/services/boards/issues/list_service.rb63
-rw-r--r--app/services/boards/issues/move_service.rb59
-rw-r--r--app/services/boards/lists/create_service.rb22
-rw-r--r--app/services/boards/lists/destroy_service.rb25
-rw-r--r--app/services/boards/lists/generate_service.rb36
-rw-r--r--app/services/boards/lists/move_service.rb51
-rw-r--r--app/views/layouts/_page.html.haml5
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/projects/boards/components/_blank_state.html.haml15
-rw-r--r--app/views/projects/boards/components/_board.html.haml44
-rw-r--r--app/views/projects/boards/components/_card.html.haml34
-rw-r--r--app/views/projects/boards/show.html.haml19
-rw-r--r--app/views/projects/issues/_head.html.haml5
-rw-r--r--app/views/shared/issuable/_filter.html.haml12
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml7
-rw-r--r--config/application.rb2
-rw-r--r--config/routes.rb14
-rw-r--r--db/migrate/20160727191041_create_boards.rb13
-rw-r--r--db/migrate/20160727193336_create_lists.rb16
-rw-r--r--db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb15
-rw-r--r--db/schema.rb24
-rw-r--r--spec/controllers/projects/boards/issues_controller_spec.rb120
-rw-r--r--spec/controllers/projects/boards/lists_controller_spec.rb241
-rw-r--r--spec/controllers/projects/boards_controller_spec.rb43
-rw-r--r--spec/factories/boards.rb5
-rw-r--r--spec/factories/lists.rb20
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/features/boards/boards_spec.rb598
-rw-r--r--spec/fixtures/api/schemas/issue.json40
-rw-r--r--spec/fixtures/api/schemas/issues.json4
-rw-r--r--spec/fixtures/api/schemas/list.json39
-rw-r--r--spec/fixtures/api/schemas/lists.json4
-rw-r--r--spec/javascripts/boards/boards_store_spec.js.es6164
-rw-r--r--spec/javascripts/boards/issue_spec.js.es683
-rw-r--r--spec/javascripts/boards/list_spec.js.es689
-rw-r--r--spec/javascripts/boards/mock_data.js.es653
-rw-r--r--spec/models/board_spec.rb12
-rw-r--r--spec/models/label_spec.rb2
-rw-r--r--spec/models/list_spec.rb117
-rw-r--r--spec/models/project_spec.rb1
-rw-r--r--spec/services/boards/create_service_spec.rb35
-rw-r--r--spec/services/boards/issues/list_service_spec.rb73
-rw-r--r--spec/services/boards/issues/move_service_spec.rb140
-rw-r--r--spec/services/boards/lists/create_service_spec.rb54
-rw-r--r--spec/services/boards/lists/destroy_service_spec.rb47
-rw-r--r--spec/services/boards/lists/generate_service_spec.rb40
-rw-r--r--spec/services/boards/lists/move_service_spec.rb110
-rw-r--r--spec/support/api/schema_matcher.rb8
-rw-r--r--vendor/assets/javascripts/Sortable.js1285
-rw-r--r--vendor/assets/javascripts/clipboard.js20
89 files changed, 5635 insertions, 83 deletions
diff --git a/CHANGELOG b/CHANGELOG
index 9299639a3ab..2e2f8049714 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -9,6 +9,7 @@ v 8.11.0 (unreleased)
- Fix the title of the toggle dropdown button. !5515 (herminiotorres)
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- Update to Ruby 2.3.1. !4948
+ - Add Issues Board !5548
- Improve diff performance by eliminating redundant checks for text blobs
- Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- Convert switch icon into icon font (ClemMakesApps)
diff --git a/Gemfile b/Gemfile
index 8b44b54e22c..a6fcc3575ff 100644
--- a/Gemfile
+++ b/Gemfile
@@ -314,6 +314,7 @@ end
group :test do
gem 'shoulda-matchers', '~> 2.8.0', require: false
gem 'email_spec', '~> 1.6.0'
+ gem 'json-schema', '~> 2.6.2'
gem 'webmock', '~> 1.21.0'
gem 'test_after_commit', '~> 0.4.2'
gem 'sham_rack', '~> 1.3.6'
diff --git a/Gemfile.lock b/Gemfile.lock
index 2244c20203b..58c84c47575 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -356,6 +356,8 @@ GEM
jquery-ui-rails (5.0.5)
railties (>= 3.2.16)
json (1.8.3)
+ json-schema (2.6.2)
+ addressable (~> 2.3.8)
jwt (1.5.4)
kaminari (0.17.0)
actionpack (>= 3.0.0)
@@ -873,6 +875,7 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
jquery-turbolinks (~> 2.1.0)
jquery-ui-rails (~> 5.0.0)
+ json-schema (~> 2.6.2)
jwt
kaminari (~> 0.17.0)
knapsack (~> 1.11.0)
diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6
new file mode 100644
index 00000000000..2c65d4427be
--- /dev/null
+++ b/app/assets/javascripts/boards/boards_bundle.js.es6
@@ -0,0 +1,57 @@
+//= require vue
+//= require vue-resource
+//= require Sortable
+//= require_tree ./models
+//= require_tree ./stores
+//= require_tree ./services
+//= require_tree ./mixins
+//= require ./components/board
+//= require ./components/new_list_dropdown
+//= require ./vue_resource_interceptor
+
+$(() => {
+ const $boardApp = document.getElementById('board-app'),
+ Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+
+ if (gl.IssueBoardsApp) {
+ gl.IssueBoardsApp.$destroy(true);
+ }
+
+ gl.IssueBoardsApp = new Vue({
+ el: $boardApp,
+ components: {
+ 'board': gl.issueBoards.Board
+ },
+ data: {
+ state: Store.state,
+ loading: true,
+ endpoint: $boardApp.dataset.endpoint,
+ disabled: $boardApp.dataset.disabled === 'true',
+ issueLinkBase: $boardApp.dataset.issueLinkBase
+ },
+ init: Store.create.bind(Store),
+ created () {
+ gl.boardService = new BoardService(this.endpoint);
+ },
+ ready () {
+ Store.disabled = this.disabled;
+ gl.boardService.all()
+ .then((resp) => {
+ resp.json().forEach((board) => {
+ const list = Store.addList(board);
+
+ if (list.type === 'done') {
+ list.position = Infinity;
+ } else if (list.type === 'backlog') {
+ list.position = -1;
+ }
+ });
+
+ Store.addBlankState();
+ this.loading = false;
+ });
+ }
+ });
+});
diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6
new file mode 100644
index 00000000000..e17784e7948
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board.js.es6
@@ -0,0 +1,85 @@
+//= require ./board_blank_state
+//= require ./board_delete
+//= require ./board_list
+
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.Board = Vue.extend({
+ components: {
+ 'board-list': gl.issueBoards.BoardList,
+ 'board-delete': gl.issueBoards.BoardDelete,
+ 'board-blank-state': gl.issueBoards.BoardBlankState
+ },
+ props: {
+ list: Object,
+ disabled: Boolean,
+ issueLinkBase: String
+ },
+ data () {
+ return {
+ query: '',
+ filters: Store.state.filters
+ };
+ },
+ watch: {
+ query () {
+ this.list.filters = this.getFilterData();
+ this.list.getIssues(true);
+ },
+ filters: {
+ handler () {
+ this.list.page = 1;
+ this.list.getIssues(true);
+ },
+ deep: true
+ }
+ },
+ methods: {
+ getFilterData () {
+ const filters = this.filters;
+ let queryData = { search: this.query };
+
+ Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; });
+
+ return queryData;
+ }
+ },
+ ready () {
+ const options = gl.issueBoards.getBoardSortableDefaultOptions({
+ disabled: this.disabled,
+ group: 'boards',
+ draggable: '.is-draggable',
+ handle: '.js-board-handle',
+ onEnd: (e) => {
+ document.body.classList.remove('is-dragging');
+
+ if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
+ const order = this.sortable.toArray(),
+ $board = this.$parent.$refs.board[e.oldIndex + 1],
+ list = $board.list;
+
+ $board.$destroy(true);
+
+ this.$nextTick(() => {
+ Store.state.lists.splice(e.newIndex, 0, list);
+ Store.moveList(list, order);
+ });
+ }
+ }
+ });
+
+ if (bp.getBreakpointSize() === 'xs') {
+ options.handle = '.js-board-drag-handle';
+ }
+
+ this.sortable = Sortable.create(this.$el.parentNode, options);
+ },
+ beforeDestroy () {
+ Store.state.lists.$remove(this.list);
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6
new file mode 100644
index 00000000000..63d72d857d9
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_blank_state.js.es6
@@ -0,0 +1,49 @@
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardBlankState = Vue.extend({
+ data () {
+ return {
+ predefinedLabels: [
+ new ListLabel({ title: 'Development', color: '#5CB85C' }),
+ new ListLabel({ title: 'Testing', color: '#F0AD4E' }),
+ new ListLabel({ title: 'Production', color: '#FF5F00' }),
+ new ListLabel({ title: 'Ready', color: '#FF0000' })
+ ]
+ }
+ },
+ methods: {
+ addDefaultLists () {
+ this.clearBlankState();
+
+ this.predefinedLabels.forEach((label, i) => {
+ Store.addList({
+ title: label.title,
+ position: i,
+ list_type: 'label',
+ label: {
+ title: label.title,
+ color: label.color
+ }
+ });
+ });
+
+ // Save the labels
+ gl.boardService.generateDefaultLists()
+ .then((resp) => {
+ resp.json().forEach((listObj) => {
+ const list = Store.findList('title', listObj.title);
+
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ list.getIssues();
+ });
+ });
+ },
+ clearBlankState: Store.removeBlankState.bind(Store)
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6
new file mode 100644
index 00000000000..4a7cfeaeab2
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card.js.es6
@@ -0,0 +1,43 @@
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardCard = Vue.extend({
+ props: {
+ list: Object,
+ issue: Object,
+ issueLinkBase: String,
+ disabled: Boolean,
+ index: Number
+ },
+ methods: {
+ filterByLabel (label, e) {
+ let labelToggleText = label.title;
+ const labelIndex = Store.state.filters['label_name'].indexOf(label.title);
+ $(e.target).tooltip('hide');
+
+ if (labelIndex === -1) {
+ Store.state.filters['label_name'].push(label.title);
+ $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`);
+ } else {
+ Store.state.filters['label_name'].splice(labelIndex, 1);
+ labelToggleText = Store.state.filters['label_name'][0];
+ $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove();
+ }
+
+ const selectedLabels = Store.state.filters['label_name'];
+ if (selectedLabels.length === 0) {
+ labelToggleText = 'Label';
+ } else if (selectedLabels.length > 1) {
+ labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`;
+ }
+
+ $('.labels-filter .dropdown-toggle-text').text(labelToggleText);
+
+ Store.updateFiltersUrl();
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6
new file mode 100644
index 00000000000..34653cd48ef
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_delete.js.es6
@@ -0,0 +1,19 @@
+(() => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardDelete = Vue.extend({
+ props: {
+ list: Object
+ },
+ methods: {
+ deleteBoard () {
+ $(this.$el).tooltip('hide');
+
+ if (confirm('Are you sure you want to delete this list?')) {
+ this.list.destroy();
+ }
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6
new file mode 100644
index 00000000000..1503d14c508
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list.js.es6
@@ -0,0 +1,89 @@
+//= require ./board_card
+
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardList = Vue.extend({
+ components: {
+ 'board-card': gl.issueBoards.BoardCard
+ },
+ props: {
+ disabled: Boolean,
+ list: Object,
+ issues: Array,
+ loading: Boolean,
+ issueLinkBase: String
+ },
+ data () {
+ return {
+ scrollOffset: 250,
+ filters: Store.state.filters
+ };
+ },
+ watch: {
+ filters: {
+ handler () {
+ this.list.loadingMore = false;
+ this.$els.list.scrollTop = 0;
+ },
+ deep: true
+ }
+ },
+ methods: {
+ listHeight () {
+ return this.$els.list.getBoundingClientRect().height;
+ },
+ scrollHeight () {
+ return this.$els.list.scrollHeight;
+ },
+ scrollTop () {
+ return this.$els.list.scrollTop + this.listHeight();
+ },
+ loadNextPage () {
+ const getIssues = this.list.nextPage();
+
+ if (getIssues) {
+ this.list.loadingMore = true;
+ getIssues.then(() => {
+ this.list.loadingMore = false;
+ });
+ }
+ },
+ },
+ ready () {
+ const options = gl.issueBoards.getBoardSortableDefaultOptions({
+ group: 'issues',
+ sort: false,
+ disabled: this.disabled,
+ onStart: (e) => {
+ const card = this.$refs.issue[e.oldIndex];
+
+ Store.moving.issue = card.issue;
+ Store.moving.list = card.list;
+ },
+ onAdd: (e) => {
+ gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue);
+ },
+ onRemove: (e) => {
+ this.$refs.issue[e.oldIndex].$destroy(true);
+ }
+ });
+
+ if (bp.getBreakpointSize() === 'xs') {
+ options.handle = '.js-card-drag-handle';
+ }
+
+ this.sortable = Sortable.create(this.$els.list, options);
+
+ // Scroll event on list to load more
+ this.$els.list.onscroll = () => {
+ if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
+ this.loadNextPage();
+ }
+ };
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6
new file mode 100644
index 00000000000..1a4d8157970
--- /dev/null
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6
@@ -0,0 +1,54 @@
+$(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ $('.js-new-board-list').each(function () {
+ const $this = $(this);
+
+ new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id'));
+
+ $this.glDropdown({
+ data(term, callback) {
+ $.get($this.attr('data-labels'))
+ .then((resp) => {
+ callback(resp);
+ });
+ },
+ renderRow (label) {
+ const active = Store.findList('title', label.title),
+ $li = $('<li />'),
+ $a = $('<a />', {
+ class: (active ? `is-active js-board-list-${active.id}` : ''),
+ text: label.title,
+ href: '#'
+ }),
+ $labelColor = $('<span />', {
+ class: 'dropdown-label-box',
+ style: `background-color: ${label.color}`
+ });
+
+ return $li.append($a.prepend($labelColor));
+ },
+ search: {
+ fields: ['title']
+ },
+ filterable: true,
+ selectable: true,
+ clicked (label, $el, e) {
+ e.preventDefault();
+
+ if (!Store.findList('title', label.title)) {
+ Store.new({
+ title: label.title,
+ position: Store.state.lists.length - 2,
+ list_type: 'label',
+ label: {
+ id: label.id,
+ title: label.title,
+ color: label.color
+ }
+ });
+ }
+ }
+ });
+ });
+});
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
new file mode 100644
index 00000000000..b7afe4897b6
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
@@ -0,0 +1,25 @@
+((w) => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
+ let defaultSortOptions = {
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+ filter: '.has-tooltip',
+ scrollSensitivity: 100,
+ scrollSpeed: 20,
+ onStart () {
+ document.body.classList.add('is-dragging');
+ },
+ onEnd () {
+ document.body.classList.remove('is-dragging');
+ }
+ }
+
+ Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
+ return defaultSortOptions;
+ };
+})(window);
diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6
new file mode 100644
index 00000000000..eb082103de9
--- /dev/null
+++ b/app/assets/javascripts/boards/models/issue.js.es6
@@ -0,0 +1,44 @@
+class ListIssue {
+ constructor (obj) {
+ this.id = obj.iid;
+ this.title = obj.title;
+ this.confidential = obj.confidential;
+ this.labels = [];
+
+ if (obj.assignee) {
+ this.assignee = new ListUser(obj.assignee);
+ }
+
+ obj.labels.forEach((label) => {
+ this.labels.push(new ListLabel(label));
+ });
+
+ this.priority = this.labels.reduce((max, label) => {
+ return (label.priority < max) ? label.priority : max;
+ }, Infinity);
+ }
+
+ addLabel (label) {
+ if (!this.findLabel(label)) {
+ this.labels.push(new ListLabel(label));
+ }
+ }
+
+ findLabel (findLabel) {
+ return this.labels.filter( label => label.title === findLabel.title )[0];
+ }
+
+ removeLabel (removeLabel) {
+ if (removeLabel) {
+ this.labels = this.labels.filter( label => removeLabel.title !== label.title );
+ }
+ }
+
+ removeLabels (labels) {
+ labels.forEach(this.removeLabel.bind(this));
+ }
+
+ getLists () {
+ return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) );
+ }
+}
diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6
new file mode 100644
index 00000000000..e81e91fe972
--- /dev/null
+++ b/app/assets/javascripts/boards/models/label.js.es6
@@ -0,0 +1,9 @@
+class ListLabel {
+ constructor (obj) {
+ this.id = obj.id;
+ this.title = obj.title;
+ this.color = obj.color;
+ this.description = obj.description;
+ this.priority = (obj.priority !== null) ? obj.priority : Infinity;
+ }
+}
diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6
new file mode 100644
index 00000000000..be2b8c568a8
--- /dev/null
+++ b/app/assets/javascripts/boards/models/list.js.es6
@@ -0,0 +1,125 @@
+class List {
+ constructor (obj) {
+ this.id = obj.id;
+ this._uid = this.guid();
+ this.position = obj.position;
+ this.title = obj.title;
+ this.type = obj.list_type;
+ this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1;
+ this.filters = gl.issueBoards.BoardsStore.state.filters;
+ this.page = 1;
+ this.loading = true;
+ this.loadingMore = false;
+ this.issues = [];
+
+ if (obj.label) {
+ this.label = new ListLabel(obj.label);
+ }
+
+ if (this.type !== 'blank' && this.id) {
+ this.getIssues();
+ }
+ }
+
+ guid() {
+ const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
+ return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
+ }
+
+ save () {
+ return gl.boardService.createList(this.label.id)
+ .then((resp) => {
+ const data = resp.json();
+
+ this.id = data.id;
+ this.type = data.list_type;
+ this.position = data.position;
+
+ return this.getIssues();
+ });
+ }
+
+ destroy () {
+ gl.issueBoards.BoardsStore.state.lists.$remove(this);
+ gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
+
+ gl.boardService.destroyList(this.id);
+ }
+
+ update () {
+ gl.boardService.updateList(this.id, this.position);
+ }
+
+ nextPage () {
+ if (Math.floor(this.issues.length / 20) === this.page) {
+ this.page++;
+
+ return this.getIssues(false);
+ }
+ }
+
+ canSearch () {
+ return this.type === 'backlog';
+ }
+
+ getIssues (emptyIssues = true) {
+ const filters = this.filters;
+ let data = { page: this.page };
+
+ Object.keys(filters).forEach((key) => { data[key] = filters[key]; });
+
+ if (this.label) {
+ data.label_name = data.label_name.filter( label => label !== this.label.title );
+ }
+
+ if (emptyIssues) {
+ this.loading = true;
+ }
+
+ return gl.boardService.getIssuesForList(this.id, data)
+ .then((resp) => {
+ const data = resp.json();
+ this.loading = false;
+
+ if (emptyIssues) {
+ this.issues = [];
+ }
+
+ this.createIssues(data);
+ });
+ }
+
+ createIssues (data) {
+ data.forEach((issueObj) => {
+ this.addIssue(new ListIssue(issueObj));
+ });
+ }
+
+ addIssue (issue, listFrom) {
+ this.issues.push(issue);
+
+ if (this.label) {
+ issue.addLabel(this.label);
+ }
+
+ if (listFrom) {
+ gl.boardService.moveIssue(issue.id, listFrom.id, this.id);
+ }
+ }
+
+ findIssue (id) {
+ return this.issues.filter( issue => issue.id === id )[0];
+ }
+
+ removeIssue (removeIssue) {
+ this.issues = this.issues.filter((issue) => {
+ const matchesRemove = removeIssue.id === issue.id;
+
+ if (matchesRemove) {
+ issue.removeLabel(this.label);
+ }
+
+ return !matchesRemove;
+ });
+ }
+}
diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6
new file mode 100644
index 00000000000..904b3a68507
--- /dev/null
+++ b/app/assets/javascripts/boards/models/user.js.es6
@@ -0,0 +1,8 @@
+class ListUser {
+ constructor (user) {
+ this.id = user.id;
+ this.name = user.name;
+ this.username = user.username;
+ this.avatar = user.avatar_url;
+ }
+}
diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6
new file mode 100644
index 00000000000..9b80fb2e99f
--- /dev/null
+++ b/app/assets/javascripts/boards/services/board_service.js.es6
@@ -0,0 +1,61 @@
+class BoardService {
+ constructor (root) {
+ Vue.http.options.root = root;
+
+ this.lists = Vue.resource(`${root}/lists{/id}`, {}, {
+ generate: {
+ method: 'POST',
+ url: `${root}/lists/generate.json`
+ }
+ });
+ this.issue = Vue.resource(`${root}/issues{/id}`, {});
+ this.issues = Vue.resource(`${root}/lists{/id}/issues`, {});
+
+ Vue.http.interceptors.push((request, next) => {
+ request.headers['X-CSRF-Token'] = $.rails.csrfToken();
+ next();
+ });
+ }
+
+ all () {
+ return this.lists.get();
+ }
+
+ generateDefaultLists () {
+ return this.lists.generate({});
+ }
+
+ createList (label_id) {
+ return this.lists.save({}, {
+ list: {
+ label_id
+ }
+ });
+ }
+
+ updateList (id, position) {
+ return this.lists.update({ id }, {
+ list: {
+ position
+ }
+ });
+ }
+
+ destroyList (id) {
+ return this.lists.delete({ id });
+ }
+
+ getIssuesForList (id, filter = {}) {
+ let data = { id };
+ Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
+
+ return this.issues.get(data);
+ }
+
+ moveIssue (id, from_list_id, to_list_id) {
+ return this.issue.update({ id }, {
+ from_list_id,
+ to_list_id
+ });
+ }
+};
diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6
new file mode 100644
index 00000000000..18f26a1f911
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/boards_store.js.es6
@@ -0,0 +1,112 @@
+(() => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardsStore = {
+ disabled: false,
+ state: {},
+ moving: {
+ issue: {},
+ list: {}
+ },
+ create () {
+ this.state.lists = [];
+ this.state.filters = {
+ author_id: gl.utils.getParameterValues('author_id')[0],
+ assignee_id: gl.utils.getParameterValues('assignee_id')[0],
+ milestone_title: gl.utils.getParameterValues('milestone_title')[0],
+ label_name: gl.utils.getParameterValues('label_name[]')
+ };
+ },
+ addList (listObj) {
+ const list = new List(listObj);
+ this.state.lists.push(list);
+
+ return list;
+ },
+ new (listObj) {
+ const list = this.addList(listObj),
+ backlogList = this.findList('type', 'backlog', 'backlog');
+
+ list
+ .save()
+ .then(() => {
+ // Remove any new issues from the backlog
+ // as they will be visible in the new list
+ list.issues.forEach(backlogList.removeIssue.bind(backlogList));
+ });
+ this.removeBlankState();
+ },
+ updateNewListDropdown (listId) {
+ $(`.js-board-list-${listId}`).removeClass('is-active');
+ },
+ shouldAddBlankState () {
+ // Decide whether to add the blank state
+ return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]);
+ },
+ addBlankState () {
+ if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
+
+ this.addList({
+ id: 'blank',
+ list_type: 'blank',
+ title: 'Welcome to your Issue Board!',
+ position: 0
+ });
+ },
+ removeBlankState () {
+ this.removeList('blank');
+
+ $.cookie('issue_board_welcome_hidden', 'true', {
+ expires: 365 * 10
+ });
+ },
+ welcomeIsHidden () {
+ return $.cookie('issue_board_welcome_hidden') === 'true';
+ },
+ removeList (id, type = 'blank') {
+ const list = this.findList('id', id, type);
+
+ if (!list) return;
+
+ this.state.lists = this.state.lists.filter( list => list.id !== id );
+ },
+ moveList (listFrom, orderLists) {
+ orderLists.forEach((id, i) => {
+ const list = this.findList('id', parseInt(id));
+
+ list.position = i;
+ });
+ listFrom.update();
+ },
+ moveIssueToList (listFrom, listTo, issue) {
+ const issueTo = listTo.findIssue(issue.id),
+ issueLists = issue.getLists(),
+ listLabels = issueLists.map( listIssue => listIssue.label );
+
+ // Add to new lists issues if it doesn't already exist
+ if (!issueTo) {
+ listTo.addIssue(issue, listFrom);
+ }
+
+ if (listTo.type === 'done' && listFrom.type !== 'backlog') {
+ issueLists.forEach((list) => {
+ list.removeIssue(issue);
+ })
+ issue.removeLabels(listLabels);
+ } else {
+ listFrom.removeIssue(issue);
+ }
+ },
+ findList (key, val, type = 'label') {
+ return this.state.lists.filter((list) => {
+ const byType = type ? list['type'] === type : true;
+
+ return list[key] === val && byType;
+ })[0];
+ },
+ updateFiltersUrl () {
+ history.pushState(null, null, `?${$.param(this.state.filters)}`);
+ }
+ };
+})();
diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/boards/test_utils/simulate_drag.js
new file mode 100755
index 00000000000..75f8b730195
--- /dev/null
+++ b/app/assets/javascripts/boards/test_utils/simulate_drag.js
@@ -0,0 +1,119 @@
+(function () {
+ 'use strict';
+
+ function simulateEvent(el, type, options) {
+ var event;
+ if (!el) return;
+ var ownerDocument = el.ownerDocument;
+
+ options = options || {};
+
+ if (/^mouse/.test(type)) {
+ event = ownerDocument.createEvent('MouseEvents');
+ event.initMouseEvent(type, true, true, ownerDocument.defaultView,
+ options.button, options.screenX, options.screenY, options.clientX, options.clientY,
+ options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
+ } else {
+ event = ownerDocument.createEvent('CustomEvent');
+
+ event.initCustomEvent(type, true, true, ownerDocument.defaultView,
+ options.button, options.screenX, options.screenY, options.clientX, options.clientY,
+ options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
+
+ event.dataTransfer = {
+ data: {},
+
+ setData: function (type, val) {
+ this.data[type] = val;
+ },
+
+ getData: function (type) {
+ return this.data[type];
+ }
+ };
+ }
+
+ if (el.dispatchEvent) {
+ el.dispatchEvent(event);
+ } else if (el.fireEvent) {
+ el.fireEvent('on' + type, event);
+ }
+
+ return event;
+ }
+
+ function getTraget(target) {
+ var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
+ var children = el.children;
+
+ return (
+ children[target.index] ||
+ children[target.index === 'first' ? 0 : -1] ||
+ children[target.index === 'last' ? children.length - 1 : -1]
+ );
+ }
+
+ function getRect(el) {
+ var rect = el.getBoundingClientRect();
+ var width = rect.right - rect.left;
+ var height = rect.bottom - rect.top;
+
+ return {
+ x: rect.left,
+ y: rect.top,
+ cx: rect.left + width / 2,
+ cy: rect.top + height / 2,
+ w: width,
+ h: height,
+ hw: width / 2,
+ wh: height / 2
+ };
+ }
+
+ function simulateDrag(options, callback) {
+ options.to.el = options.to.el || options.from.el;
+
+ var fromEl = getTraget(options.from);
+ var toEl = getTraget(options.to);
+ var scrollable = options.scrollable;
+
+ var fromRect = getRect(fromEl);
+ var toRect = getRect(toEl);
+
+ var startTime = new Date().getTime();
+ var duration = options.duration || 1000;
+ simulateEvent(fromEl, 'mousedown', {button: 0});
+ options.ontap && options.ontap();
+ window.SIMULATE_DRAG_ACTIVE = 1;
+
+ var dragInterval = setInterval(function loop() {
+ var progress = (new Date().getTime() - startTime) / duration;
+ var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft;
+ var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop;
+ var overEl = fromEl.ownerDocument.elementFromPoint(x, y);
+
+ simulateEvent(overEl, 'mousemove', {
+ clientX: x,
+ clientY: y
+ });
+
+ if (progress >= 1) {
+ options.ondragend && options.ondragend();
+ simulateEvent(toEl, 'mouseup');
+ clearInterval(dragInterval);
+ window.SIMULATE_DRAG_ACTIVE = 0;
+ }
+ }, 100);
+
+ return {
+ target: fromEl,
+ fromList: fromEl.parentNode,
+ toList: toEl.parentNode
+ };
+ }
+
+
+ // Export
+ window.simulateEvent = simulateEvent;
+ window.simulateDrag = simulateDrag;
+})();
diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
new file mode 100644
index 00000000000..66f645a4b61
--- /dev/null
+++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
@@ -0,0 +1,8 @@
+Vue.http.interceptors.push((request, next) => {
+ Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
+
+ setTimeout(() => {
+ Vue.activeResources--;
+ }, 500);
+ next();
+});
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
index c82798cc6a5..c43af17442b 100644
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ b/app/assets/javascripts/copy_to_clipboard.js
@@ -34,6 +34,7 @@
$(function() {
var clipboard;
+
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
return clipboard.on('error', genericError);
diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6
new file mode 100644
index 00000000000..46d1c3f00c1
--- /dev/null
+++ b/app/assets/javascripts/create_label.js.es6
@@ -0,0 +1,126 @@
+(function (w) {
+ class CreateLabelDropdown {
+ constructor ($el, projectId) {
+ this.$el = $el;
+ this.projectId = projectId;
+ this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown'));
+ this.$cancelButton = $('.js-cancel-label-btn', this.$el);
+ this.$newLabelField = $('#new_label_name', this.$el);
+ this.$newColorField = $('#new_label_color', this.$el);
+ this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
+ this.$newLabelError = $('.js-label-error', this.$el);
+ this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
+ this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
+
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.disable();
+
+ this.cleanBinding();
+ this.addBinding();
+ }
+
+ cleanBinding () {
+ this.$colorSuggestions.off('click');
+ this.$newLabelField.off('keyup change');
+ this.$newColorField.off('keyup change');
+ this.$dropdownBack.off('click');
+ this.$cancelButton.off('click');
+ this.$newLabelCreateButton.off('click');
+ }
+
+ addBinding () {
+ const self = this;
+
+ this.$colorSuggestions.on('click', function (e) {
+ const $this = $(this);
+ self.addColorValue(e, $this);
+ });
+
+ this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
+ this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
+
+ this.$dropdownBack.on('click', this.resetForm.bind(this));
+
+ this.$cancelButton.on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ self.resetForm();
+ self.$dropdownBack.trigger('click');
+ });
+
+ this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
+ }
+
+ addColorValue (e, $this) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.$newColorField.val($this.data('color')).trigger('change');
+ this.$colorPreview
+ .css('background-color', $this.data('color'))
+ .parent()
+ .addClass('is-active');
+ }
+
+ enableLabelCreateButton () {
+ if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.enable();
+ } else {
+ this.$newLabelCreateButton.disable();
+ }
+ }
+
+ resetForm () {
+ this.$newLabelField
+ .val('')
+ .trigger('change');
+
+ this.$newColorField
+ .val('')
+ .trigger('change');
+
+ this.$colorPreview
+ .css('background-color', '')
+ .parent()
+ .removeClass('is-active');
+ }
+
+ saveLabel (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ Api.newLabel(this.projectId, {
+ name: this.$newLabelField.val(),
+ color: this.$newColorField.val()
+ }, (label) => {
+ this.$newLabelCreateButton.enable();
+
+ if (label.message) {
+ let errors;
+
+ if (typeof label.message === 'string') {
+ errors = label.message;
+ } else {
+ errors = label.message.map(function (value, key) {
+ return key + " " + value[0];
+ }).join("<br/>");
+ }
+
+ this.$newLabelError
+ .html(errors)
+ .show();
+ } else {
+ this.$dropdownBack.trigger('click');
+ }
+ });
+ }
+ }
+
+ if (!w.gl) {
+ w.gl = {};
+ }
+
+ gl.CreateLabelDropdown = CreateLabelDropdown;
+})(window);
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 675dd5b7cea..0526430989f 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -4,7 +4,7 @@
var _this;
_this = this;
$('.js-label-select').each(function(i, dropdown) {
- var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo;
+ var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
labelUrl = $dropdown.data('labels');
@@ -13,8 +13,6 @@
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(',');
}
- newLabelField = $('#new_label_name');
- newColorField = $('#new_label_color');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
defaultLabel = $dropdown.data('default-label');
@@ -24,10 +22,6 @@
$form = $dropdown.closest('form');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$value = $block.find('.value');
- $newLabelError = $('.js-label-error');
- $colorPreview = $('.js-dropdown-label-color-preview');
- $newLabelCreateButton = $('.js-new-label-btn');
- $newLabelError.hide();
$loading = $block.find('.block-loading').fadeOut();
if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/');
@@ -36,60 +30,9 @@
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
- if (newLabelField.length) {
- $('.suggest-colors-dropdown a').on("click", function(e) {
- e.preventDefault();
- e.stopPropagation();
- newColorField.val($(this).data('color')).trigger('change');
- return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active');
- });
- resetForm = function() {
- newLabelField.val('').trigger('change');
- newColorField.val('').trigger('change');
- return $colorPreview.css('background-color', '').parent().removeClass('is-active');
- };
- $('.dropdown-menu-back').on('click', function() {
- return resetForm();
- });
- $('.js-cancel-label-btn').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- resetForm();
- return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
- });
- enableLabelCreateButton = function() {
- if (newLabelField.val() !== '' && newColorField.val() !== '') {
- $newLabelError.hide();
- return $newLabelCreateButton.enable();
- } else {
- return $newLabelCreateButton.disable();
- }
- };
- saveLabel = function() {
- return Api.newLabel(projectId, {
- name: newLabelField.val(),
- color: newColorField.val()
- }, function(label) {
- var errors;
- $newLabelCreateButton.enable();
- if (label.message != null) {
- errors = _.map(label.message, function(value, key) {
- return key + " " + value[0];
- });
- return $newLabelError.html(errors.join("<br/>")).show();
- } else {
- return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
- }
- });
- };
- newLabelField.on('keyup change', enableLabelCreateButton);
- newColorField.on('keyup change', enableLabelCreateButton);
- $newLabelCreateButton.disable().on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- return saveLabel();
- });
- }
+
+ new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId);
+
saveLabelData = function() {
var data, selected;
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() {
@@ -270,6 +213,9 @@
isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide();
$value.removeAttr('style');
+ if (page === 'projects:boards:show') {
+ return;
+ }
if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
@@ -289,7 +235,7 @@
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
- clicked: function(label) {
+ clicked: function(label, $el, e) {
var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown();
if ($dropdown.hasClass('js-filter-bulk-update')) {
@@ -298,7 +244,23 @@
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ if (label.isAny) {
+ gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
+ } else if (label.title) {
+ gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title);
+ } else {
+ var filters = gl.issueBoards.BoardsStore.state.filters['label_name'];
+ filters = filters.filter(function (label) {
+ return label !== $el.text().trim();
+ });
+ gl.issueBoards.BoardsStore.state.filters['label_name'] = filters;
+ }
+
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ return;
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form'));
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index a0b65d20c03..e897ebdf630 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -94,7 +94,7 @@
$selectbox.hide();
return $value.css('display', '');
},
- clicked: function(selected) {
+ clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
@@ -102,7 +102,11 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (selected.name != null) {
selectedMilestone = selected.name;
} else {
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 65d362e072c..bad82868ab0 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -141,7 +141,7 @@
$selectbox.hide();
return $value.css('display', '');
},
- clicked: function(user) {
+ clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
@@ -149,7 +149,12 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ selectedId = user.id;
+ gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedId = user.id;
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index ca720022539..5da390118c6 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb;
$ci-output-bg: #1d1f21;
$ci-text-color: #c5c8c6;
+
+$issue-boards-font-size: 15px;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
new file mode 100644
index 00000000000..ad4b2d6496f
--- /dev/null
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -0,0 +1,329 @@
+[v-cloak] {
+ display: none;
+}
+
+.user-can-drag {
+ cursor: -webkit-grab;
+ cursor: grab;
+}
+
+.is-dragging {
+ * {
+ cursor: -webkit-grabbing;
+ cursor: grabbing;
+ }
+}
+
+.dropdown-menu-issues-board-new {
+ width: 320px;
+
+ .dropdown-content {
+ max-height: 150px;
+ }
+}
+
+.issue-board-dropdown-content {
+ margin: 0 8px 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid $dropdown-divider-color;
+
+ > p {
+ margin: 0;
+ font-size: 14px;
+ color: #9c9c9c;
+ }
+}
+
+.issue-boards-page {
+ .content-wrapper {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ }
+
+ .sub-nav,
+ .issues-filters {
+ -webkit-flex: none;
+ flex: none;
+ }
+
+ .page-with-sidebar {
+ display: -webkit-flex;
+ display: flex;
+ min-height: 100vh;
+ max-height: 100vh;
+ padding-bottom: 0;
+ }
+
+ .issue-boards-content {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+ width: 100%;
+
+ .content {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ }
+ }
+}
+
+.boards-app-loading {
+ width: 100%;
+ font-size: 34px;
+}
+
+.boards-list {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+ -webkit-flex-basis: 0;
+ flex-basis: 0;
+ min-height: calc(100vh - 152px);
+ max-height: calc(100vh - 152px);
+ padding-top: 25px;
+ padding-right: ($gl-padding / 2);
+ padding-left: ($gl-padding / 2);
+ overflow-x: scroll;
+
+ @media (min-width: $screen-sm-min) {
+ min-height: 475px;
+ max-height: none;
+ }
+}
+
+.board {
+ display: -webkit-flex;
+ display: flex;
+ min-width: calc(100vw - 15px);
+ max-width: calc(100vw - 15px);
+ margin-bottom: 25px;
+ padding-right: ($gl-padding / 2);
+ padding-left: ($gl-padding / 2);
+
+ @media (min-width: $screen-sm-min) {
+ min-width: 400px;
+ max-width: 400px;
+ }
+}
+
+.board-inner {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ font-size: $issue-boards-font-size;
+ background: $background-color;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+}
+
+.board-header {
+ border-top-left-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+
+ &.has-border {
+ border-top: 3px solid;
+
+ .board-title {
+ padding-top: ($gl-padding - 3px);
+ }
+ }
+}
+
+.board-header-loading-spinner {
+ margin-right: 10px;
+ color: $gray-darkest;
+}
+
+.board-inner-container {
+ border-bottom: 1px solid $border-color;
+ padding: $gl-padding;
+}
+
+.board-title {
+ position: relative;
+ margin: 0;
+ padding: $gl-padding;
+ font-size: 1em;
+ border-bottom: 1px solid $border-color;
+
+ .board-mobile-handle {
+ position: relative;
+ left: 0;
+ top: 1px;
+ margin-top: 0;
+ margin-right: 5px;
+ }
+}
+
+.board-search-container {
+ position: relative;
+ background-color: #fff;
+
+ .form-control {
+ padding-right: 30px;
+ }
+}
+
+.board-search-icon,
+.board-search-clear-btn {
+ position: absolute;
+ right: $gl-padding + 10px;
+ top: 50%;
+ margin-top: -7px;
+ font-size: 14px;
+}
+
+.board-search-icon {
+ color: $gl-placeholder-color;
+}
+
+.board-search-clear-btn {
+ padding: 0;
+ line-height: 1;
+ background: transparent;
+ border: 0;
+ outline: 0;
+
+ &:hover {
+ color: $gl-link-color;
+ }
+}
+
+.board-delete {
+ margin-right: 10px;
+ padding: 0;
+ color: $gray-darkest;
+ background-color: transparent;
+ border: 0;
+ outline: 0;
+
+ &:hover {
+ color: $gl-link-color;
+ }
+}
+
+.board-blank-state {
+ height: 100%;
+ padding: $gl-padding;
+ background-color: #fff;
+}
+
+.board-blank-state-list {
+ list-style: none;
+
+ > li:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .label-color {
+ position: relative;
+ top: 2px;
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-right: 3px;
+ border-radius: $border-radius-default;
+ }
+}
+
+.board-list {
+ -webkit-flex: 1;
+ flex: 1;
+ height: 400px;
+ margin-bottom: 0;
+ padding: 5px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+
+.board-list-loading {
+ margin-top: 10px;
+ font-size: 26px;
+}
+
+.is-ghost {
+ opacity: 0.3;
+}
+
+.is-dragging {
+ // Important because plugin sets inline CSS
+ opacity: 1!important;
+}
+
+.card {
+ position: relative;
+ width: 100%;
+ padding: 10px $gl-padding;
+ background: #fff;
+ border-radius: $border-radius-default;
+ box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5);
+ list-style: none;
+
+ &.user-can-drag {
+ padding-left: ($gl-padding * 2);
+
+ @media (min-width: $screen-sm-min) {
+ padding-left: $gl-padding;
+ }
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 5px;
+ }
+
+ a {
+ cursor: pointer;
+ }
+
+ .label {
+ border: 0;
+ outline: 0;
+ }
+
+ .confidential-icon {
+ margin-right: 5px;
+ }
+}
+
+.board-mobile-handle {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ margin-top: (-15px / 2);
+
+ @media (min-width: $screen-sm-min) {
+ display: none;
+ }
+}
+
+.card-title {
+ margin: 0;
+ font-size: 1em;
+
+ a {
+ color: inherit;
+ }
+}
+
+.card-footer {
+ margin-top: 5px;
+
+ .label {
+ margin-right: 4px;
+ font-size: (14px / $issue-boards-font-size) * 1em;
+ }
+}
+
+.card-number {
+ margin-right: 8px;
+ font-weight: 500;
+}
diff --git a/app/controllers/projects/board_lists_controller.rb b/app/controllers/projects/board_lists_controller.rb
new file mode 100644
index 00000000000..3cfb08d5822
--- /dev/null
+++ b/app/controllers/projects/board_lists_controller.rb
@@ -0,0 +1,65 @@
+class Projects::BoardListsController < Projects::ApplicationController
+ respond_to :json
+
+ before_action :authorize_admin_list!
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ def create
+ list = Boards::Lists::CreateService.new(project, current_user, list_params).execute
+
+ if list.valid?
+ render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ service = Boards::Lists::MoveService.new(project, current_user, move_params)
+
+ if service.execute
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ service = Boards::Lists::DestroyService.new(project, current_user, params)
+
+ if service.execute
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = Boards::Lists::GenerateService.new(project, current_user)
+
+ if service.execute
+ render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def authorize_admin_list!
+ return render_403 unless can?(current_user, :admin_list, project)
+ end
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position).merge(id: params[:id])
+ end
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+end
diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb
new file mode 100644
index 00000000000..dad38fff6b9
--- /dev/null
+++ b/app/controllers/projects/boards/application_controller.rb
@@ -0,0 +1,15 @@
+module Projects
+ module Boards
+ class ApplicationController < Projects::ApplicationController
+ respond_to :json
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ private
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
new file mode 100644
index 00000000000..2d894b3dd4a
--- /dev/null
+++ b/app/controllers/projects/boards/issues_controller.rb
@@ -0,0 +1,56 @@
+module Projects
+ module Boards
+ class IssuesController < Boards::ApplicationController
+ before_action :authorize_read_issue!, only: [:index]
+ before_action :authorize_update_issue!, only: [:update]
+
+ def index
+ issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
+ issues = issues.page(params[:page])
+
+ render json: issues.as_json(
+ only: [:iid, :title, :confidential],
+ include: {
+ assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
+ labels: { only: [:id, :title, :description, :color, :priority] }
+ })
+ end
+
+ def update
+ service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
+
+ if service.execute(issue)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def issue
+ @issue ||=
+ IssuesFinder.new(current_user, project_id: project.id, state: 'all')
+ .execute
+ .where(iid: params[:id])
+ .first!
+ end
+
+ def authorize_read_issue!
+ return render_403 unless can?(current_user, :read_issue, project)
+ end
+
+ def authorize_update_issue!
+ return render_403 unless can?(current_user, :update_issue, issue)
+ end
+
+ def filter_params
+ params.merge(id: params[:list_id])
+ end
+
+ def move_params
+ params.permit(:id, :from_list_id, :to_list_id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
new file mode 100644
index 00000000000..b995f586737
--- /dev/null
+++ b/app/controllers/projects/boards/lists_controller.rb
@@ -0,0 +1,81 @@
+module Projects
+ module Boards
+ class ListsController < Boards::ApplicationController
+ before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
+ before_action :authorize_read_list!, only: [:index]
+
+ def index
+ render json: serialize_as_json(project.board.lists)
+ end
+
+ def create
+ list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute
+
+ if list.valid?
+ render json: serialize_as_json(list)
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ list = project.board.lists.movable.find(params[:id])
+ service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ list = project.board.lists.destroyable.find(params[:id])
+ service = ::Boards::Lists::DestroyService.new(project, current_user, params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = ::Boards::Lists::GenerateService.new(project, current_user)
+
+ if service.execute
+ render json: serialize_as_json(project.board.lists.movable)
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def authorize_admin_list!
+ return render_403 unless can?(current_user, :admin_list, project)
+ end
+
+ def authorize_read_list!
+ return render_403 unless can?(current_user, :read_list, project)
+ end
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(
+ only: [:id, :list_type, :position],
+ methods: [:title],
+ include: {
+ label: { only: [:id, :title, :description, :color, :priority] }
+ })
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
new file mode 100644
index 00000000000..33206717089
--- /dev/null
+++ b/app/controllers/projects/boards_controller.rb
@@ -0,0 +1,15 @@
+class Projects::BoardsController < Projects::ApplicationController
+ respond_to :html
+
+ before_action :authorize_read_board!, only: [:show]
+
+ def show
+ ::Boards::CreateService.new(project, current_user).execute
+ end
+
+ private
+
+ def authorize_read_board!
+ return access_denied! unless can?(current_user, :read_board, project)
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index c3613bc67dd..f3733b01721 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -320,4 +320,8 @@ module ApplicationHelper
capture(&block)
end
end
+
+ def page_class
+ "issue-boards-page" if current_controller?(:boards)
+ end
end
diff --git a/app/models/ability.rb b/app/models/ability.rb
index d9113ffd99a..55265c3cfcb 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -90,6 +90,8 @@ class Ability
if project && project.public?
rules = [
:read_project,
+ :read_board,
+ :read_list,
:read_wiki,
:read_label,
:read_milestone,
@@ -228,6 +230,8 @@ class Ability
:read_project,
:read_wiki,
:read_issue,
+ :read_board,
+ :read_list,
:read_label,
:read_milestone,
:read_project_snippet,
@@ -249,6 +253,7 @@ class Ability
:update_issue,
:admin_issue,
:admin_label,
+ :admin_list,
:read_commit_status,
:read_build,
:read_container_image,
diff --git a/app/models/board.rb b/app/models/board.rb
new file mode 100644
index 00000000000..3240c4bede3
--- /dev/null
+++ b/app/models/board.rb
@@ -0,0 +1,7 @@
+class Board < ActiveRecord::Base
+ belongs_to :project
+
+ has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
+
+ validates :project, presence: true
+end
diff --git a/app/models/label.rb b/app/models/label.rb
index 35e678001dc..a23140b7d64 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -13,6 +13,8 @@ class Label < ActiveRecord::Base
default_value_for :color, DEFAULT_COLOR
belongs_to :project
+
+ has_many :lists, dependent: :destroy
has_many :label_links, dependent: :destroy
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
diff --git a/app/models/list.rb b/app/models/list.rb
new file mode 100644
index 00000000000..eb87decdbc8
--- /dev/null
+++ b/app/models/list.rb
@@ -0,0 +1,34 @@
+class List < ActiveRecord::Base
+ belongs_to :board
+ belongs_to :label
+
+ enum list_type: { backlog: 0, label: 1, done: 2 }
+
+ validates :board, :list_type, presence: true
+ validates :label, :position, presence: true, if: :label?
+ validates :label_id, uniqueness: { scope: :board_id }, if: :label?
+ validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
+
+ before_destroy :can_be_destroyed
+
+ scope :destroyable, -> { where(list_type: list_types[:label]) }
+ scope :movable, -> { where(list_type: list_types[:label]) }
+
+ def destroyable?
+ label?
+ end
+
+ def movable?
+ label?
+ end
+
+ def title
+ label? ? label.name : list_type.humanize
+ end
+
+ private
+
+ def can_be_destroyed
+ destroyable?
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index e0b28160937..c5e2bcbf4ed 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -62,6 +62,8 @@ class Project < ActiveRecord::Base
belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id'
belongs_to :namespace
+ has_one :board, dependent: :destroy
+
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id'
# Project services
diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb
new file mode 100644
index 00000000000..b2069ca825a
--- /dev/null
+++ b/app/services/boards/base_service.rb
@@ -0,0 +1,5 @@
+module Boards
+ class BaseService < ::BaseService
+ delegate :board, to: :project
+ end
+end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
new file mode 100644
index 00000000000..072a0749285
--- /dev/null
+++ b/app/services/boards/create_service.rb
@@ -0,0 +1,16 @@
+module Boards
+ class CreateService < Boards::BaseService
+ def execute
+ create_board! unless project.board.present?
+ project.board
+ end
+
+ private
+
+ def create_board!
+ project.create_board
+ project.board.lists.create(list_type: :backlog)
+ project.board.lists.create(list_type: :done)
+ end
+ end
+end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
new file mode 100644
index 00000000000..435a8c6e681
--- /dev/null
+++ b/app/services/boards/issues/list_service.rb
@@ -0,0 +1,63 @@
+module Boards
+ module Issues
+ class ListService < Boards::BaseService
+ def execute
+ issues = IssuesFinder.new(current_user, filter_params).execute
+ issues = without_board_labels(issues) unless list.movable?
+ issues = with_list_label(issues) if list.movable?
+ issues
+ end
+
+ private
+
+ def list
+ @list ||= board.lists.find(params[:id])
+ end
+
+ def filter_params
+ set_default_scope
+ set_default_sort
+ set_project
+ set_state
+
+ params
+ end
+
+ def set_default_scope
+ params[:scope] = 'all'
+ end
+
+ def set_default_sort
+ params[:sort] = 'priority'
+ end
+
+ def set_project
+ params[:project_id] = project.id
+ end
+
+ def set_state
+ params[:state] = list.done? ? 'closed' : 'opened'
+ end
+
+ def board_label_ids
+ @board_label_ids ||= board.lists.movable.pluck(:label_id)
+ end
+
+ def without_board_labels(issues)
+ return issues unless board_label_ids.any?
+
+ issues.where.not(
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
+ .where(label_id: board_label_ids).limit(1).arel.exists
+ )
+ end
+
+ def with_list_label(issues)
+ issues.where(
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
+ .where("label_links.label_id = ?", list.label_id).limit(1).arel.exists
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
new file mode 100644
index 00000000000..84dc3f70e76
--- /dev/null
+++ b/app/services/boards/issues/move_service.rb
@@ -0,0 +1,59 @@
+module Boards
+ module Issues
+ class MoveService < Boards::BaseService
+ def execute(issue)
+ return false unless can?(current_user, :update_issue, issue)
+ return false unless valid_move?
+
+ update_service.execute(issue)
+ end
+
+ private
+
+ def valid_move?
+ moving_from_list.present? && moving_to_list.present? &&
+ moving_from_list != moving_to_list
+ end
+
+ def moving_from_list
+ @moving_from_list ||= board.lists.find_by(id: params[:from_list_id])
+ end
+
+ def moving_to_list
+ @moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
+ end
+
+ def update_service
+ ::Issues::UpdateService.new(project, current_user, issue_params)
+ end
+
+ def issue_params
+ {
+ add_label_ids: add_label_ids,
+ remove_label_ids: remove_label_ids,
+ state_event: issue_state
+ }
+ end
+
+ def issue_state
+ return 'reopen' if moving_from_list.done?
+ return 'close' if moving_to_list.done?
+ end
+
+ def add_label_ids
+ [moving_to_list.label_id].compact
+ end
+
+ def remove_label_ids
+ label_ids =
+ if moving_to_list.movable?
+ moving_from_list.label_id
+ else
+ board.lists.movable.pluck(:label_id)
+ end
+
+ Array(label_ids).compact
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
new file mode 100644
index 00000000000..5cb408b9d20
--- /dev/null
+++ b/app/services/boards/lists/create_service.rb
@@ -0,0 +1,22 @@
+module Boards
+ module Lists
+ class CreateService < Boards::BaseService
+ def execute
+ List.transaction do
+ create_list_at(next_position)
+ end
+ end
+
+ private
+
+ def next_position
+ max_position = board.lists.movable.maximum(:position)
+ max_position.nil? ? 0 : max_position.succ
+ end
+
+ def create_list_at(position)
+ board.lists.create(params.merge(list_type: :label, position: position))
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
new file mode 100644
index 00000000000..25da3bfb56d
--- /dev/null
+++ b/app/services/boards/lists/destroy_service.rb
@@ -0,0 +1,25 @@
+module Boards
+ module Lists
+ class DestroyService < Boards::BaseService
+ def execute(list)
+ return false unless list.destroyable?
+
+ list.with_lock do
+ decrement_higher_lists(list)
+ remove_list(list)
+ end
+ end
+
+ private
+
+ def decrement_higher_lists(list)
+ board.lists.movable.where('position > ?', list.position)
+ .update_all('position = position - 1')
+ end
+
+ def remove_list(list)
+ list.destroy
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb
new file mode 100644
index 00000000000..1c48b9786e4
--- /dev/null
+++ b/app/services/boards/lists/generate_service.rb
@@ -0,0 +1,36 @@
+module Boards
+ module Lists
+ class GenerateService < Boards::BaseService
+ def execute
+ return false unless board.lists.movable.empty?
+
+ List.transaction do
+ label_params.each { |params| create_list(params) }
+ end
+
+ true
+ end
+
+ private
+
+ def create_list(params)
+ label = find_or_create_label(params)
+ Lists::CreateService.new(project, current_user, label_id: label.id).execute
+ end
+
+ def find_or_create_label(params)
+ project.labels.create_with(color: params[:color])
+ .find_or_create_by(name: params[:name])
+ end
+
+ def label_params
+ [
+ { name: 'Development', color: '#5CB85C' },
+ { name: 'Testing', color: '#F0AD4E' },
+ { name: 'Production', color: '#FF5F00' },
+ { name: 'Ready', color: '#FF0000' }
+ ]
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb
new file mode 100644
index 00000000000..020ff69f4a7
--- /dev/null
+++ b/app/services/boards/lists/move_service.rb
@@ -0,0 +1,51 @@
+module Boards
+ module Lists
+ class MoveService < Boards::BaseService
+ def execute(list)
+ @old_position = list.position
+ @new_position = params[:position]
+
+ return false unless list.movable?
+ return false unless valid_move?
+
+ list.with_lock do
+ reorder_intermediate_lists
+ update_list_position(list)
+ end
+ end
+
+ private
+
+ attr_reader :old_position, :new_position
+
+ def valid_move?
+ new_position.present? && new_position != old_position &&
+ new_position >= 0 && new_position < board.lists.movable.size
+ end
+
+ def reorder_intermediate_lists
+ if old_position < new_position
+ decrement_intermediate_lists
+ else
+ increment_intermediate_lists
+ end
+ end
+
+ def decrement_intermediate_lists
+ board.lists.movable.where('position > ?', old_position)
+ .where('position <= ?', new_position)
+ .update_all('position = position - 1')
+ end
+
+ def increment_intermediate_lists
+ board.lists.movable.where('position >= ?', new_position)
+ .where('position < ?', old_position)
+ .update_all('position = position + 1')
+ end
+
+ def update_list_position(list)
+ list.update_attribute(:position, new_position)
+ end
+ end
+ end
+end
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index a1a71c2fb33..bf50633af24 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -23,7 +23,6 @@
= render "layouts/broadcast"
= render "layouts/flash"
= yield :flash_message
- %div{ class: (container_class unless @no_container) }
+ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content
- .clearfix
- = yield
+ = yield
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 33cedaaf2ee..15a94ac23c5 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,5 +1,5 @@
!!! 5
-%html{ lang: "en"}
+%html{ lang: "en", class: "#{page_class}" }
= render "layouts/head"
%body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}}
= Gon::Base.render_data
diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml
new file mode 100644
index 00000000000..97eb952eff1
--- /dev/null
+++ b/app/views/projects/boards/components/_blank_state.html.haml
@@ -0,0 +1,15 @@
+%board-blank-state{ "inline-template" => true,
+ "v-if" => "list.id == 'blank'" }
+ .board-blank-state
+ %p
+ Add the following default lists to your Issue Board with one click:
+ %ul.board-blank-state-list
+ %li{ "v-for" => "label in predefinedLabels" }
+ %span.label-color{ ":style" => "{ backgroundColor: label.color } " }
+ {{ label.title }}
+ %p
+ Starting out with the default set of lists will get you right on the way to making the most of your board.
+ %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" }
+ Add default lists
+ %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" }
+ Nevermind, I'll use my own
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
new file mode 100644
index 00000000000..f8ebf397ee2
--- /dev/null
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -0,0 +1,44 @@
+%board{ "inline-template" => true,
+ "v-cloak" => true,
+ "v-for" => "list in state.lists | orderBy 'position'",
+ "v-ref:board" => true,
+ ":list" => "list",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase",
+ "track-by" => "_uid" }
+ .board{ ":class" => "{ 'is-draggable': !list.preset }",
+ ":data-id" => "list.id" }
+ .board-inner
+ %header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
+ %h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" }
+ = icon("align-justify", class: "board-mobile-handle js-board-drag-handle", "v-if" => "(!disabled && !list.preset)")
+ {{ list.title }}
+ %span.pull-right{ "v-if" => "list.type !== 'blank'" }
+ {{ list.issues.length }}
+ - if can?(current_user, :admin_list, @project)
+ %board-delete{ "inline-template" => true,
+ ":list" => "list",
+ "v-if" => "!list.preset && list.id" }
+ %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
+ = icon("trash")
+ = icon("spinner spin", class: "board-header-loading-spinner pull-right", "v-show" => "list.loadingMore")
+ .board-inner-container.board-search-container{ "v-if" => "list.canSearch()" }
+ %input.form-control{ type: "text", placeholder: "Search issues", "v-model" => "query", "debounce" => "250" }
+ = icon("search", class: "board-search-icon", "v-show" => "!query")
+ %button.board-search-clear-btn{ type: "button", role: "button", "aria-label" => "Clear search", "@click" => "query = ''", "v-show" => "query" }
+ = icon("times", class: "board-search-clear")
+ %board-list{ "inline-template" => true,
+ "v-if" => "list.type !== 'blank'",
+ ":list" => "list",
+ ":issues" => "list.issues",
+ ":loading" => "list.loading",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase" }
+ .board-list-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ %ul.board-list{ "v-el:list" => true,
+ "v-show" => "!loading",
+ ":data-board" => "list.id" }
+ = render "projects/boards/components/card"
+ - if can?(current_user, :admin_list, @project)
+ = render "projects/boards/components/blank_state"
diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml
new file mode 100644
index 00000000000..b20c23f6b8e
--- /dev/null
+++ b/app/views/projects/boards/components/_card.html.haml
@@ -0,0 +1,34 @@
+%board-card{ "inline-template" => true,
+ "v-for" => "issue in issues | orderBy 'priority'",
+ "v-ref:issue" => true,
+ ":index" => "$index",
+ ":list" => "list",
+ ":issue" => "issue",
+ ":issue-link-base" => "issueLinkBase",
+ ":disabled" => "disabled",
+ "track-by" => "id" }
+ %li.card{ ":class" => "{ 'user-can-drag': !disabled }",
+ ":index" => "index" }
+ = icon("align-justify", class: "board-mobile-handle js-card-drag-handle", "v-if" => "!disabled")
+ %h4.card-title
+ = icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential")
+ %a{ ":href" => "issueLinkBase + '/' + issue.id",
+ ":title" => "issue.title" }
+ {{ issue.title }}
+ .card-footer
+ %span.card-number
+ = precede '#' do
+ {{ issue.id }}
+ %button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels",
+ type: "button",
+ "v-if" => "(!list.label || label.id !== list.label.id)",
+ "@click" => "filterByLabel(label, $event)",
+ ":style" => "{ backgroundColor: label.color, color: label.textColor }",
+ ":title" => "label.description",
+ data: { container: 'body' } }
+ {{ label.title }}
+ %a.has-tooltip{ ":href" => "'/u/' + issue.assignee.username",
+ ":title" => "'Assigned to ' + issue.assignee.name",
+ "v-if" => "issue.assignee",
+ data: { container: 'body' } }
+ %img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 }
diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml
new file mode 100644
index 00000000000..edbbd3f3d2a
--- /dev/null
+++ b/app/views/projects/boards/show.html.haml
@@ -0,0 +1,19 @@
+- @no_container = true
+- @content_class = "issue-boards-content"
+- page_title "Boards"
+
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('boards/boards_bundle.js')
+ = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test?
+
+= render "projects/issues/head"
+
+= render 'shared/issuable/filter', type: :boards
+
+.boards-list#board-app{ "v-cloak" => true,
+ "data-endpoint" => "#{namespace_project_board_path(@project.namespace, @project)}",
+ "data-disabled" => "#{!can?(current_user, :admin_list, @project)}",
+ "data-issue-link-base" => "#{namespace_project_issues_path(@project.namespace, @project)}" }
+ .boards-app-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ = render "projects/boards/components/board"
diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml
index 60b45115b73..b6cb559afcb 100644
--- a/app/views/projects/issues/_head.html.haml
+++ b/app/views/projects/issues/_head.html.haml
@@ -6,6 +6,11 @@
%span
Issues
+ = nav_link(controller: :boards) do
+ = link_to namespace_project_board_path(@project.namespace, @project), title: 'Board' do
+ %span
+ Board
+
- if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
= nav_link(controller: :merge_requests) do
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 0b7fa8c7d06..cb095063336 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -27,7 +27,17 @@
= render "shared/issuable/label_dropdown"
.pull-right
- = render 'shared/sort_dropdown'
+ - if controller.controller_name != 'boards'
+ = render 'shared/sort_dropdown'
+ - if can?(current_user, :admin_list, @project)
+ .dropdown
+ %button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, project_id: @project.try(:id) } }
+ Create new list
+ .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
+ = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" }
+ - if can?(current_user, :admin_label, @project)
+ = render partial: "shared/issuable/label_page_create"
+ = dropdown_loading
- if controller.controller_name == 'issues'
.issues_bulk_update.hide
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index 4e280c371ac..a76b7baf918 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -2,8 +2,13 @@
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels')
+- show_boards_content = local_assigns.fetch(:show_boards_content, false)
.dropdown-page-one
= dropdown_title(title)
+ - if show_boards_content
+ .issue-board-dropdown-content
+ %p
+ Each label that exists in your issue tracker can have its own dedicated list. Select a label below to add a list to your Board and it will automatically be populated with issues that have that label. To create a list for a label that doesn't exist yet, simply create the label below.
= dropdown_filter(filter_placeholder)
= dropdown_content
- if @project && show_footer
@@ -12,7 +17,7 @@
- if can?(current_user, :admin_label, @project)
%li
%a.dropdown-toggle-page{href: "#"}
- Create new
+ Create new label
%li
= link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
diff --git a/config/application.rb b/config/application.rb
index 4a9ed41cbf8..0c136623477 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -85,6 +85,8 @@ module Gitlab
config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_bundle.js"
+ config.assets.precompile << "boards/boards_bundle.js"
+ config.assets.precompile << "boards/test_utils/simulate_drag.js"
config.assets.precompile << "lib/utils/*.js"
config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js"
diff --git a/config/routes.rb b/config/routes.rb
index 1d2db91344f..293d9e69813 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -851,6 +851,20 @@ Rails.application.routes.draw do
end
end
+ resource :board, only: [:show] do
+ scope module: :boards do
+ resources :issues, only: [:update]
+
+ resources :lists, only: [:index, :create, :update, :destroy] do
+ collection do
+ post :generate
+ end
+
+ resources :issues, only: [:index]
+ end
+ end
+ end
+
resources :todos, only: [:create]
resources :uploads, only: [:create] do
diff --git a/db/migrate/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb
new file mode 100644
index 00000000000..56afbd4e030
--- /dev/null
+++ b/db/migrate/20160727191041_create_boards.rb
@@ -0,0 +1,13 @@
+class CreateBoards < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :boards do |t|
+ t.references :project, index: true, foreign_key: true, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20160727193336_create_lists.rb b/db/migrate/20160727193336_create_lists.rb
new file mode 100644
index 00000000000..61d501215f2
--- /dev/null
+++ b/db/migrate/20160727193336_create_lists.rb
@@ -0,0 +1,16 @@
+class CreateLists < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :lists do |t|
+ t.references :board, index: true, foreign_key: true, null: false
+ t.references :label, index: true, foreign_key: true
+ t.integer :list_type, null: false, default: 1
+ t.integer :position
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb
new file mode 100644
index 00000000000..baf2e70b127
--- /dev/null
+++ b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb
@@ -0,0 +1,15 @@
+class AddUniqueIndexToListsLabelId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :lists, [:board_id, :label_id], unique: true
+ end
+
+ def down
+ remove_index :lists, column: [:board_id, :label_id] if index_exists?(:lists, [:board_id, :label_id], unique: true)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 52ba60ace11..666a4bef574 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -117,6 +117,14 @@ ActiveRecord::Schema.define(version: 20160810142633) do
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree
+ create_table "boards", force: :cascade do |t|
+ t.integer "project_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
+
create_table "broadcast_messages", force: :cascade do |t|
t.text "message", null: false
t.datetime "starts_at"
@@ -533,6 +541,19 @@ ActiveRecord::Schema.define(version: 20160810142633) do
add_index "lfs_objects_projects", ["project_id"], name: "index_lfs_objects_projects_on_project_id", using: :btree
+ create_table "lists", force: :cascade do |t|
+ t.integer "board_id", null: false
+ t.integer "label_id"
+ t.integer "list_type", default: 1, null: false
+ t.integer "position"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree
+ add_index "lists", ["board_id"], name: "index_lists_on_board_id", using: :btree
+ add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree
+
create_table "members", force: :cascade do |t|
t.integer "access_level", null: false
t.integer "source_id", null: false
@@ -1114,6 +1135,9 @@ ActiveRecord::Schema.define(version: 20160810142633) do
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
+ add_foreign_key "boards", "projects"
+ add_foreign_key "lists", "boards"
+ add_foreign_key "lists", "labels"
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "protected_branch_merge_access_levels", "protected_branches"
add_foreign_key "protected_branch_push_access_levels", "protected_branches"
diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb
new file mode 100644
index 00000000000..d0ad5e26dbd
--- /dev/null
+++ b/spec/controllers/projects/boards/issues_controller_spec.rb
@@ -0,0 +1,120 @@
+require 'spec_helper'
+
+describe Projects::Boards::IssuesController do
+ let(:project) { create(:project_with_board) }
+ let(:user) { create(:user) }
+
+ let(:planning) { create(:label, project: project, name: 'Planning') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+
+ let!(:list1) { create(:list, board: project.board, label: planning, position: 0) }
+ let!(:list2) { create(:list, board: project.board, label: development, position: 1) }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ describe 'GET index' do
+ context 'with valid list id' do
+ it 'returns issues that have the list label applied' do
+ johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png')))
+ create(:labeled_issue, project: project, labels: [planning])
+ create(:labeled_issue, project: project, labels: [development])
+ create(:labeled_issue, project: project, labels: [development], assignee: johndoe)
+
+ list_issues user: user, list_id: list2
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(response).to match_response_schema('issues')
+ expect(parsed_response.length).to eq 2
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'returns a not found 404 response' do
+ list_issues user: user, list_id: 999
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_issue, project).and_return(false)
+ end
+
+ it 'returns a successful 403 response' do
+ list_issues user: user, list_id: list2
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def list_issues(user:, list_id:)
+ sign_in(user)
+
+ get :index, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ list_id: list_id.to_param
+ end
+ end
+
+ describe 'PATCH update' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
+
+ context 'with valid params' do
+ it 'returns a successful 200 response' do
+ move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'moves issue to the desired list' do
+ move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(issue.reload.labels).to contain_exactly(development)
+ end
+ end
+
+ context 'with invalid params' do
+ it 'returns a unprocessable entity 422 response for invalid lists' do
+ move user: user, issue: issue, from_list_id: nil, to_list_id: nil
+
+ expect(response).to have_http_status(422)
+ end
+
+ it 'returns a not found 404 response for invalid issue id' do
+ move user: user, issue: 999, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ let(:guest) { create(:user) }
+
+ before do
+ project.team << [guest, :guest]
+ end
+
+ it 'returns a successful 403 response' do
+ move user: guest, issue: issue, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def move(user:, issue:, from_list_id:, to_list_id:)
+ sign_in(user)
+
+ patch :update, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: issue.to_param,
+ from_list_id: from_list_id,
+ to_list_id: to_list_id,
+ format: :json
+ end
+ end
+end
diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb
new file mode 100644
index 00000000000..9496636e3cc
--- /dev/null
+++ b/spec/controllers/projects/boards/lists_controller_spec.rb
@@ -0,0 +1,241 @@
+require 'spec_helper'
+
+describe Projects::Boards::ListsController do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+ let(:guest) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ project.team << [guest, :guest]
+ end
+
+ describe 'GET index' do
+ it 'returns a successful 200 response' do
+ read_board_list user: user
+
+ expect(response).to have_http_status(200)
+ expect(response.content_type).to eq 'application/json'
+ end
+
+ it 'returns a list of board lists' do
+ board = project.create_board
+ create(:backlog_list, board: board)
+ create(:list, board: board)
+ create(:done_list, board: board)
+
+ read_board_list user: user
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(response).to match_response_schema('lists')
+ expect(parsed_response.length).to eq 3
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_list, project).and_return(false)
+ end
+
+ it 'returns a successful 403 response' do
+ read_board_list user: user
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def read_board_list(user:)
+ sign_in(user)
+
+ get :index, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ format: :json
+ end
+ end
+
+ describe 'POST create' do
+ let(:label) { create(:label, project: project, name: 'Development') }
+
+ context 'with valid params' do
+ it 'returns a successful 200 response' do
+ create_board_list user: user, label_id: label.id
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the created list' do
+ create_board_list user: user, label_id: label.id
+
+ expect(response).to match_response_schema('list')
+ end
+ end
+
+ context 'with invalid params' do
+ it 'returns an error' do
+ create_board_list user: user, label_id: nil
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(parsed_response['label']).to contain_exactly "can't be blank"
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'with unauthorized user' do
+ let(:label) { create(:label, project: project, name: 'Development') }
+
+ it 'returns a successful 403 response' do
+ create_board_list user: guest, label_id: label.id
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def create_board_list(user:, label_id:)
+ sign_in(user)
+
+ post :create, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ list: { label_id: label_id },
+ format: :json
+ end
+ end
+
+ describe 'PATCH update' do
+ let!(:planning) { create(:list, board: board, position: 0) }
+ let!(:development) { create(:list, board: board, position: 1) }
+
+ context 'with valid position' do
+ it 'returns a successful 200 response' do
+ move user: user, list: planning, position: 1
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'moves the list to the desired position' do
+ move user: user, list: planning, position: 1
+
+ expect(planning.reload.position).to eq 1
+ end
+ end
+
+ context 'with invalid position' do
+ it 'returns a unprocessable entity 422 response' do
+ move user: user, list: planning, position: 6
+
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'returns a not found 404 response' do
+ move user: user, list: 999, position: 1
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a successful 403 response' do
+ move user: guest, list: planning, position: 6
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def move(user:, list:, position:)
+ sign_in(user)
+
+ patch :update, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: list.to_param,
+ list: { position: position },
+ format: :json
+ end
+ end
+
+ describe 'DELETE destroy' do
+ let!(:planning) { create(:list, board: board, position: 0) }
+
+ context 'with valid list id' do
+ it 'returns a successful 200 response' do
+ remove_board_list user: user, list: planning
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'removes list from board' do
+ expect { remove_board_list user: user, list: planning }.to change(board.lists, :size).by(-1)
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'returns a not found 404 response' do
+ remove_board_list user: user, list: 999
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a successful 403 response' do
+ remove_board_list user: guest, list: planning
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def remove_board_list(user:, list:)
+ sign_in(user)
+
+ delete :destroy, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: list.to_param,
+ format: :json
+ end
+ end
+
+ describe 'POST generate' do
+ context 'when board lists is empty' do
+ it 'returns a successful 200 response' do
+ generate_default_board_lists user: user
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the defaults lists' do
+ generate_default_board_lists user: user
+
+ expect(response).to match_response_schema('lists')
+ end
+ end
+
+ context 'when board lists is not empty' do
+ it 'returns a unprocessable entity 422 response' do
+ create(:list, board: board)
+
+ generate_default_board_lists user: user
+
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a successful 403 response' do
+ generate_default_board_lists user: guest
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def generate_default_board_lists(user:)
+ sign_in(user)
+
+ post :generate, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ format: :json
+ end
+ end
+end
diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb
new file mode 100644
index 00000000000..75a6d39e82c
--- /dev/null
+++ b/spec/controllers/projects/boards_controller_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Projects::BoardsController do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ describe 'GET show' do
+ it 'creates a new board when project does not have one' do
+ expect { read_board }.to change(Board, :count).by(1)
+ end
+
+ it 'renders HTML template' do
+ read_board
+
+ expect(response).to render_template :show
+ expect(response.content_type).to eq 'text/html'
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_board, project).and_return(false)
+ end
+
+ it 'returns a successful 404 response' do
+ read_board
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ def read_board(format: :html)
+ get :show, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ format: format
+ end
+ end
+end
diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb
new file mode 100644
index 00000000000..35c4a0b6f08
--- /dev/null
+++ b/spec/factories/boards.rb
@@ -0,0 +1,5 @@
+FactoryGirl.define do
+ factory :board do
+ project factory: :empty_project
+ end
+end
diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb
new file mode 100644
index 00000000000..9e3f06c682c
--- /dev/null
+++ b/spec/factories/lists.rb
@@ -0,0 +1,20 @@
+FactoryGirl.define do
+ factory :list do
+ board
+ label
+ list_type :label
+ sequence(:position)
+ end
+
+ factory :backlog_list, parent: :list do
+ list_type :backlog
+ label nil
+ position nil
+ end
+
+ factory :done_list, parent: :list do
+ list_type :done
+ label nil
+ position nil
+ end
+end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index b682ced75ac..f82d68a1816 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -83,4 +83,10 @@ FactoryGirl.define do
)
end
end
+
+ factory :project_with_board, parent: :empty_project do
+ after(:create) do |project|
+ project.create_board
+ end
+ end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
new file mode 100644
index 00000000000..e4c5a10ce7e
--- /dev/null
+++ b/spec/features/boards/boards_spec.rb
@@ -0,0 +1,598 @@
+require 'rails_helper'
+
+describe 'Issue Boards', feature: true, js: true do
+ include WaitForAjax
+
+ let(:project) { create(:empty_project, :public) }
+ let(:user) { create(:user) }
+ let!(:user2) { create(:user) }
+
+ before do
+ project.create_board
+ project.board.lists.create(list_type: :backlog)
+ project.board.lists.create(list_type: :done)
+
+ project.team << [user, :master]
+ project.team << [user2, :master]
+
+ login_as(user)
+ end
+
+ context 'no lists' do
+ before do
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ expect(page).to have_selector('.board', count: 3)
+ end
+
+ it 'shows blank state' do
+ expect(page).to have_content('Welcome to your Issue Board!')
+ end
+
+ it 'hides the blank state when clicking nevermind button' do
+ page.within(find('.board-blank-state')) do
+ click_button("Nevermind, I'll use my own")
+ end
+ expect(page).to have_selector('.board', count: 2)
+ end
+
+ it 'creates default lists' do
+ lists = ['Backlog', 'Development', 'Testing', 'Production', 'Ready', 'Done']
+
+ page.within(find('.board-blank-state')) do
+ click_button('Add default lists')
+ end
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 6)
+
+ page.all('.board').each_with_index do |list, i|
+ expect(list.find('.board-title')).to have_content(lists[i])
+ end
+ end
+ end
+
+ context 'with lists' do
+ let(:milestone) { create(:milestone, project: project) }
+
+ let(:planning) { create(:label, project: project, name: 'Planning') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let!(:backlog) { create(:label, project: project, name: 'Backlog') }
+ let!(:done) { create(:label, project: project, name: 'Done') }
+
+ let!(:list1) { create(:list, board: project.board, label: planning, position: 0) }
+ let!(:list2) { create(:list, board: project.board, label: development, position: 1) }
+
+ let!(:confidential_issue) { create(:issue, :confidential, project: project, author: user) }
+ let!(:issue1) { create(:issue, project: project, assignee: user) }
+ let!(:issue2) { create(:issue, project: project, author: user2) }
+ let!(:issue3) { create(:issue, project: project) }
+ let!(:issue4) { create(:issue, project: project) }
+ let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) }
+ let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) }
+ let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) }
+ let!(:issue8) { create(:closed_issue, project: project) }
+ let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug]) }
+
+ before do
+ visit namespace_project_board_path(project.namespace, project)
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 4)
+ expect(find('.board:nth-child(1)')).to have_selector('.card')
+ expect(find('.board:nth-child(2)')).to have_selector('.card')
+ expect(find('.board:nth-child(3)')).to have_selector('.card')
+ expect(find('.board:nth-child(4)')).to have_selector('.card')
+ end
+
+ it 'shows lists' do
+ expect(page).to have_selector('.board', count: 4)
+ end
+
+ it 'shows issues in lists' do
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('2')
+ expect(page).to have_selector('.card', count: 2)
+ end
+
+ page.within(find('.board:nth-child(3)')) do
+ expect(page.find('.board-header')).to have_content('2')
+ expect(page).to have_selector('.card', count: 2)
+ end
+ end
+
+ it 'shows confidential issues with icon' do
+ page.within(find('.board', match: :first)) do
+ expect(page).to have_selector('.confidential-icon', count: 1)
+ end
+ end
+
+ it 'allows user to delete board' do
+ page.within(find('.board:nth-child(2)')) do
+ find('.board-delete').click
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 3)
+ end
+
+ it 'removes checkmark in new list dropdown after deleting' do
+ click_button 'Create new list'
+ wait_for_ajax
+
+ page.within(find('.board:nth-child(2)')) do
+ find('.board-delete').click
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 3)
+ expect(find(".js-board-list-#{planning.id}", visible: false)).not_to have_css('.is-active')
+ end
+
+ it 'infinite scrolls list' do
+ 50.times do
+ create(:issue, project: project)
+ end
+
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('20')
+ expect(page).to have_selector('.card', count: 20)
+
+ evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+
+ expect(page.find('.board-header')).to have_content('40')
+ expect(page).to have_selector('.card', count: 40)
+ end
+ end
+
+ context 'backlog' do
+ it 'shows issues in backlog with no labels' do
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('6')
+ expect(page).to have_selector('.card', count: 6)
+ end
+ end
+
+ it 'is searchable' do
+ page.within(find('.board', match: :first)) do
+ find('.form-control').set issue1.title
+
+ expect(page).to have_selector('.card', count: 1)
+ end
+ end
+
+ it 'clears search' do
+ page.within(find('.board', match: :first)) do
+ find('.form-control').set issue1.title
+
+ expect(page).to have_selector('.card', count: 1)
+
+ find('.board-search-clear-btn').click
+
+ expect(page).to have_selector('.card', count: 6)
+ end
+ end
+
+ it 'moves issue from backlog into list' do
+ drag_to(list_to_index: 1)
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('5')
+ expect(page).to have_selector('.card', count: 5)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('3')
+ expect(page).to have_selector('.card', count: 3)
+ end
+ end
+ end
+
+ context 'done' do
+ it 'shows list of done issues' do
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1)
+ end
+
+ it 'moves issue to done' do
+ drag_to(list_from_index: 0, list_to_index: 3)
+
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2)
+ expect(find('.board:nth-child(4)')).to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
+ end
+
+ it 'removes all of the same issue to done' do
+ drag_to(list_from_index: 1, list_to_index: 3)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(4)')).to have_content(issue6.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
+ end
+ end
+
+ context 'lists' do
+ it 'changes position of list' do
+ drag_to(list_from_index: 1, list_to_index: 2, selector: '.board-header')
+
+ expect(find('.board:nth-child(2)')).to have_content(development.title)
+ expect(find('.board:nth-child(2)')).to have_content(planning.title)
+ end
+
+ it 'issue moves between lists' do
+ drag_to(list_from_index: 1, card_index: 1, list_to_index: 2)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 3)
+ expect(find('.board:nth-child(3)')).to have_content(issue6.title)
+ expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title)
+ end
+
+ it 'issue moves between lists' do
+ drag_to(list_from_index: 2, list_to_index: 1)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(2)')).to have_content(issue7.title)
+ expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title)
+ end
+
+ it 'issue moves from done' do
+ drag_to(list_from_index: 3, list_to_index: 1)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3)
+ expect(find('.board:nth-child(2)')).to have_content(issue8.title)
+ end
+
+ context 'issue card' do
+ it 'shows assignee' do
+ page.within(find('.board', match: :first)) do
+ expect(page).to have_selector('.avatar', count: 1)
+ end
+ end
+ end
+
+ context 'new list' do
+ it 'shows all labels in new list dropdown' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ expect(page).to have_content(planning.title)
+ expect(page).to have_content(development.title)
+ expect(page).to have_content(testing.title)
+ end
+ end
+
+ it 'creates new list for label' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link testing.title
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 5)
+ end
+
+ it 'creates new list for Backlog label' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link backlog.title
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 5)
+ end
+
+ it 'creates new list for Done label' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link done.title
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 5)
+ end
+
+ it 'moves issues from backlog into new list' do
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('6')
+ expect(page).to have_selector('.card', count: 6)
+ end
+
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link testing.title
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('5')
+ expect(page).to have_selector('.card', count: 5)
+ end
+ end
+ end
+ end
+
+ context 'filtering' do
+ it 'filters by author' do
+ page.within '.issues-filters' do
+ click_button('Author')
+
+ page.within '.dropdown-menu-author' do
+ click_link(user2.name)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(find('.js-author-search')).to have_content(user2.name)
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by assignee' do
+ page.within '.issues-filters' do
+ click_button('Assignee')
+
+ page.within '.dropdown-menu-assignee' do
+ click_link(user.name)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(find('.js-assignee-search')).to have_content(user.name)
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by milestone' do
+ page.within '.issues-filters' do
+ click_button('Milestone')
+
+ page.within '.milestone-filter' do
+ click_link(milestone.title)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(find('.js-milestone-select')).to have_content(milestone.title)
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+ end
+
+ it 'filters by label' do
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link(testing.title)
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'infinite scrolls list with label filter' do
+ 50.times do
+ create(:labeled_issue, project: project, labels: [testing])
+ end
+
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link(testing.title)
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('20')
+ expect(page).to have_selector('.card', count: 20)
+
+ evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+
+ expect(page.find('.board-header')).to have_content('40')
+ expect(page).to have_selector('.card', count: 40)
+ end
+ end
+
+ it 'filters by multiple labels' do
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link(testing.title)
+ wait_for_vue_resource(spinner: false)
+ click_link(bug.title)
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by no label' do
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link("No Label")
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('5')
+ expect(page).to have_selector('.card', count: 5)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by clicking label button on issue' do
+ page.within(find('.board', match: :first)) do
+ expect(page).to have_selector('.card', count: 6)
+ click_button(bug.title)
+ wait_for_vue_resource(spinner: false)
+ end
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+
+ page.within('.labels-filter') do
+ expect(find('.dropdown-toggle-text')).to have_content(bug.title)
+ end
+ end
+
+ it 'removes label filter by clicking label button on issue' do
+ page.within(find('.board', match: :first)) do
+ page.within(find('.card', match: :first)) do
+ click_button(bug.title)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ wait_for_vue_resource
+
+ page.within('.labels-filter') do
+ expect(find('.dropdown-toggle-text')).to have_content(bug.title)
+ end
+ end
+ end
+ end
+
+ context 'signed out user' do
+ before do
+ logout
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ end
+
+ it 'does not show create new list' do
+ expect(page).not_to have_selector('.js-new-board-list')
+ end
+ end
+
+ context 'as guest user' do
+ let(:user_guest) { create(:user) }
+
+ before do
+ project.team << [user_guest, :guest]
+ logout
+ login_as(user_guest)
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ end
+
+ it 'does not show create new list' do
+ expect(page).not_to have_selector('.js-new-board-list')
+ end
+ end
+
+ def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list')
+ evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});")
+
+ Timeout.timeout(Capybara.default_max_wait_time) do
+ loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero?
+ end
+
+ wait_for_vue_resource
+ end
+
+ def wait_for_vue_resource(spinner: true)
+ Timeout.timeout(Capybara.default_max_wait_time) do
+ loop until page.evaluate_script('Vue.activeResources').zero?
+ end
+
+ if spinner
+ expect(find('.boards-list')).not_to have_selector('.fa-spinner')
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json
new file mode 100644
index 00000000000..299e4675d6f
--- /dev/null
+++ b/spec/fixtures/api/schemas/issue.json
@@ -0,0 +1,40 @@
+{
+ "type": "object",
+ "required" : [
+ "iid",
+ "title",
+ "confidential"
+ ],
+ "properties" : {
+ "iid": { "type": "integer" },
+ "title": { "type": "string" },
+ "confidential": { "type": "boolean" },
+ "labels": {
+ "type": ["array"],
+ "required": [
+ "id",
+ "color",
+ "description",
+ "title",
+ "priority"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
+ },
+ "description": { "type": ["string", "null"] },
+ "title": { "type": "string" },
+ "priority": { "type": ["integer", "null"] }
+ }
+ },
+ "assignee": {
+ "id": { "type": "integet" },
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "avatar_url": { "type": "uri" }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/issues.json b/spec/fixtures/api/schemas/issues.json
new file mode 100644
index 00000000000..0d2067f704a
--- /dev/null
+++ b/spec/fixtures/api/schemas/issues.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "issue.json" }
+}
diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json
new file mode 100644
index 00000000000..f070fa3b254
--- /dev/null
+++ b/spec/fixtures/api/schemas/list.json
@@ -0,0 +1,39 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "list_type",
+ "title",
+ "position"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "list_type": {
+ "type": "string",
+ "enum": ["backlog", "label", "done"]
+ },
+ "label": {
+ "type": ["object"],
+ "required": [
+ "id",
+ "color",
+ "description",
+ "title",
+ "priority"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
+ },
+ "description": { "type": ["string", "null"] },
+ "title": { "type": "string" },
+ "priority": { "type": ["integer", "null"] }
+ }
+ },
+ "title": { "type": "string" },
+ "position": { "type": ["integer", "null"] }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/lists.json b/spec/fixtures/api/schemas/lists.json
new file mode 100644
index 00000000000..9f618aa9de5
--- /dev/null
+++ b/spec/fixtures/api/schemas/lists.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "list.json" }
+}
diff --git a/spec/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js.es6
new file mode 100644
index 00000000000..078e4b00023
--- /dev/null
+++ b/spec/javascripts/boards/boards_store_spec.js.es6
@@ -0,0 +1,164 @@
+//= require jquery
+//= require jquery_ujs
+//= require jquery.cookie
+//= require vue
+//= require vue-resource
+//= require lib/utils/url_utility
+//= require boards/models/issue
+//= require boards/models/label
+//= require boards/models/list
+//= require boards/models/user
+//= require boards/services/board_service
+//= require boards/stores/boards_store
+//= require ./mock_data
+
+(() => {
+ beforeEach(() => {
+ gl.boardService = new BoardService('/test/issue-boards/board');
+ gl.issueBoards.BoardsStore.create();
+
+ $.cookie('issue_board_welcome_hidden', 'false');
+ });
+
+ describe('Store', () => {
+ it('starts with a blank state', () => {
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
+ });
+
+ describe('lists', () => {
+ it('creates new list without persisting to DB', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+ });
+
+ it('finds list by ID', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ const list = gl.issueBoards.BoardsStore.findList('id', 1);
+
+ expect(list.id).toBe(1);
+ });
+
+ it('finds list by type', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ const list = gl.issueBoards.BoardsStore.findList('type', 'label');
+
+ expect(list).toBeDefined();
+ });
+
+ it('finds list limited by type', () => {
+ gl.issueBoards.BoardsStore.addList({
+ id: 1,
+ position: 0,
+ title: 'Test',
+ list_type: 'backlog'
+ });
+ const list = gl.issueBoards.BoardsStore.findList('id', 1, 'backlog');
+
+ expect(list).toBeDefined();
+ });
+
+ it('gets issue when new list added', (done) => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ const list = gl.issueBoards.BoardsStore.findList('id', 1);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ setTimeout(() => {
+ expect(list.issues.length).toBe(1);
+ expect(list.issues[0].id).toBe(1);
+ done();
+ }, 0);
+ });
+
+ it('persists new list', (done) => {
+ gl.issueBoards.BoardsStore.new({
+ title: 'Test',
+ type: 'label',
+ label: {
+ id: 1,
+ title: 'Testing',
+ color: 'red',
+ description: 'testing;'
+ }
+ });
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ setTimeout(() => {
+ const list = gl.issueBoards.BoardsStore.findList('id', 1);
+ expect(list).toBeDefined();
+ expect(list.id).toBe(1);
+ expect(list.position).toBe(0);
+ done();
+ }, 0);
+ });
+
+ it('check for blank state adding', () => {
+ expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true);
+ });
+
+ it('check for blank state not adding', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(false);
+ });
+
+ it('check for blank state adding when backlog & done list exist', () => {
+ gl.issueBoards.BoardsStore.addList({
+ list_type: 'backlog'
+ });
+ gl.issueBoards.BoardsStore.addList({
+ list_type: 'done'
+ });
+
+ expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true);
+ });
+
+ it('adds the blank state', () => {
+ gl.issueBoards.BoardsStore.addBlankState();
+
+ const list = gl.issueBoards.BoardsStore.findList('type', 'blank', 'blank');
+ expect(list).toBeDefined();
+ });
+
+ it('removes list from state', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ gl.issueBoards.BoardsStore.removeList(1, 'label');
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
+ });
+
+ it('moves the position of lists', () => {
+ const listOne = gl.issueBoards.BoardsStore.addList(listObj),
+ listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
+
+ gl.issueBoards.BoardsStore.moveList(listOne, ['2', '1']);
+
+ expect(listOne.position).toBe(1);
+ });
+
+ it('moves an issue from one list to another', (done) => {
+ const listOne = gl.issueBoards.BoardsStore.addList(listObj),
+ listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
+
+ setTimeout(() => {
+ expect(listOne.issues.length).toBe(1);
+ expect(listTwo.issues.length).toBe(1);
+
+ gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1));
+
+ expect(listOne.issues.length).toBe(0);
+ expect(listTwo.issues.length).toBe(1);
+
+ done();
+ }, 0);
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/boards/issue_spec.js.es6 b/spec/javascripts/boards/issue_spec.js.es6
new file mode 100644
index 00000000000..3569d1b98bd
--- /dev/null
+++ b/spec/javascripts/boards/issue_spec.js.es6
@@ -0,0 +1,83 @@
+//= require jquery
+//= require jquery_ujs
+//= require jquery.cookie
+//= require vue
+//= require vue-resource
+//= require lib/utils/url_utility
+//= require boards/models/issue
+//= require boards/models/label
+//= require boards/models/list
+//= require boards/models/user
+//= require boards/services/board_service
+//= require boards/stores/boards_store
+//= require ./mock_data
+
+describe('Issue model', () => {
+ let issue;
+
+ beforeEach(() => {
+ gl.boardService = new BoardService('/test/issue-boards/board');
+ gl.issueBoards.BoardsStore.create();
+
+ issue = new ListIssue({
+ title: 'Testing',
+ iid: 1,
+ confidential: false,
+ labels: [{
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing'
+ }]
+ });
+ });
+
+ it('has label', () => {
+ expect(issue.labels.length).toBe(1);
+ });
+
+ it('add new label', () => {
+ issue.addLabel({
+ id: 2,
+ title: 'bug',
+ color: 'blue',
+ description: 'bugs!'
+ });
+ expect(issue.labels.length).toBe(2);
+ });
+
+ it('does not add existing label', () => {
+ issue.addLabel({
+ id: 2,
+ title: 'test',
+ color: 'blue',
+ description: 'bugs!'
+ });
+
+ expect(issue.labels.length).toBe(1);
+ });
+
+ it('finds label', () => {
+ const label = issue.findLabel(issue.labels[0]);
+ expect(label).toBeDefined();
+ });
+
+ it('removes label', () => {
+ const label = issue.findLabel(issue.labels[0]);
+ issue.removeLabel(label);
+ expect(issue.labels.length).toBe(0);
+ });
+
+ it('removes multiple labels', () => {
+ issue.addLabel({
+ id: 2,
+ title: 'bug',
+ color: 'blue',
+ description: 'bugs!'
+ });
+ expect(issue.labels.length).toBe(2);
+
+ issue.removeLabels([issue.labels[0], issue.labels[1]]);
+ expect(issue.labels.length).toBe(0);
+ });
+});
diff --git a/spec/javascripts/boards/list_spec.js.es6 b/spec/javascripts/boards/list_spec.js.es6
new file mode 100644
index 00000000000..c206b794442
--- /dev/null
+++ b/spec/javascripts/boards/list_spec.js.es6
@@ -0,0 +1,89 @@
+//= require jquery
+//= require jquery_ujs
+//= require jquery.cookie
+//= require vue
+//= require vue-resource
+//= require lib/utils/url_utility
+//= require boards/models/issue
+//= require boards/models/label
+//= require boards/models/list
+//= require boards/models/user
+//= require boards/services/board_service
+//= require boards/stores/boards_store
+//= require ./mock_data
+
+describe('List model', () => {
+ let list;
+
+ beforeEach(() => {
+ gl.boardService = new BoardService('/test/issue-boards/board');
+ gl.issueBoards.BoardsStore.create();
+
+ list = new List(listObj);
+ });
+
+ it('gets issues when created', (done) => {
+ setTimeout(() => {
+ expect(list.issues.length).toBe(1);
+ done();
+ }, 0);
+ });
+
+ it('saves list and returns ID', (done) => {
+ list = new List({
+ title: 'test',
+ label: {
+ id: 1,
+ title: 'test',
+ color: 'red'
+ }
+ });
+ list.save();
+
+ setTimeout(() => {
+ expect(list.id).toBe(1);
+ expect(list.type).toBe('label');
+ expect(list.position).toBe(0);
+ done();
+ }, 0);
+ });
+
+ it('destroys the list', (done) => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ list = gl.issueBoards.BoardsStore.findList('id', 1);
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+ list.destroy();
+
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
+ done();
+ }, 0);
+ });
+
+ it('can\'t search when not backlog', () => {
+ expect(list.canSearch()).toBe(false);
+ });
+
+ it('can search when backlog', () => {
+ list.type = 'backlog';
+ expect(list.canSearch()).toBe(true);
+ });
+
+ it('gets issue from list', (done) => {
+ setTimeout(() => {
+ const issue = list.findIssue(1);
+ expect(issue).toBeDefined();
+ done();
+ }, 0);
+ });
+
+ it('removes issue', (done) => {
+ setTimeout(() => {
+ const issue = list.findIssue(1);
+ expect(list.issues.length).toBe(1);
+ list.removeIssue(issue);
+ expect(list.issues.length).toBe(0);
+ done();
+ }, 0);
+ });
+});
diff --git a/spec/javascripts/boards/mock_data.js.es6 b/spec/javascripts/boards/mock_data.js.es6
new file mode 100644
index 00000000000..0c37ec8354f
--- /dev/null
+++ b/spec/javascripts/boards/mock_data.js.es6
@@ -0,0 +1,53 @@
+const listObj = {
+ id: 1,
+ position: 0,
+ title: 'Test',
+ list_type: 'label',
+ label: {
+ id: 1,
+ title: 'Testing',
+ color: 'red',
+ description: 'testing;'
+ }
+};
+
+const listObjDuplicate = {
+ id: 2,
+ position: 1,
+ title: 'Test',
+ list_type: 'label',
+ label: {
+ id: 2,
+ title: 'Testing',
+ color: 'red',
+ description: 'testing;'
+ }
+};
+
+const BoardsMockData = {
+ 'GET': {
+ '/test/issue-boards/board/lists{/id}/issues': [{
+ title: 'Testing',
+ iid: 1,
+ confidential: false,
+ labels: []
+ }]
+ },
+ 'POST': {
+ '/test/issue-boards/board/lists{/id}': listObj
+ },
+ 'PUT': {
+ '/test/issue-boards/board/lists{/id}': {}
+ },
+ 'DELETE': {
+ '/test/issue-boards/board/lists{/id}': {}
+ }
+};
+
+Vue.http.interceptors.push((request, next) => {
+ const body = BoardsMockData[request.method][request.url];
+
+ next(request.respondWith(JSON.stringify(body), {
+ status: 200
+ }));
+});
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
new file mode 100644
index 00000000000..12d29540137
--- /dev/null
+++ b/spec/models/board_spec.rb
@@ -0,0 +1,12 @@
+require 'rails_helper'
+
+describe Board do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+end
diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb
index 2a09063f857..5a5d1a5d60c 100644
--- a/spec/models/label_spec.rb
+++ b/spec/models/label_spec.rb
@@ -5,8 +5,10 @@ describe Label, models: true do
describe 'associations' do
it { is_expected.to belong_to(:project) }
+
it { is_expected.to have_many(:label_links).dependent(:destroy) }
it { is_expected.to have_many(:issues).through(:label_links).source(:target) }
+ it { is_expected.to have_many(:lists).dependent(:destroy) }
end
describe 'modules' do
diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb
new file mode 100644
index 00000000000..9e1a52011c3
--- /dev/null
+++ b/spec/models/list_spec.rb
@@ -0,0 +1,117 @@
+require 'rails_helper'
+
+describe List do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:board) }
+ it { is_expected.to belong_to(:label) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:board) }
+ it { is_expected.to validate_presence_of(:label) }
+ it { is_expected.to validate_presence_of(:list_type) }
+ it { is_expected.to validate_presence_of(:position) }
+ it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) }
+
+ it 'validates uniqueness of label scoped to board_id' do
+ create(:list)
+
+ expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id)
+ end
+
+ context 'when list_type is set to backlog' do
+ subject { described_class.new(list_type: :backlog) }
+
+ it { is_expected.not_to validate_presence_of(:label) }
+ it { is_expected.not_to validate_presence_of(:position) }
+ end
+
+ context 'when list_type is set to done' do
+ subject { described_class.new(list_type: :done) }
+
+ it { is_expected.not_to validate_presence_of(:label) }
+ it { is_expected.not_to validate_presence_of(:position) }
+ end
+ end
+
+ describe '#destroy' do
+ it 'can be destroyed when when list_type is set to label' do
+ subject = create(:list)
+
+ expect(subject.destroy).to be_truthy
+ end
+
+ it 'can not be destroyed when list_type is set to backlog' do
+ subject = create(:backlog_list)
+
+ expect(subject.destroy).to be_falsey
+ end
+
+ it 'can not be destroyed when when list_type is set to done' do
+ subject = create(:done_list)
+
+ expect(subject.destroy).to be_falsey
+ end
+ end
+
+ describe '#destroyable?' do
+ it 'retruns true when list_type is set to label' do
+ subject.list_type = :label
+
+ expect(subject).to be_destroyable
+ end
+
+ it 'retruns false when list_type is set to backlog' do
+ subject.list_type = :backlog
+
+ expect(subject).not_to be_destroyable
+ end
+
+ it 'retruns false when list_type is set to done' do
+ subject.list_type = :done
+
+ expect(subject).not_to be_destroyable
+ end
+ end
+
+ describe '#movable?' do
+ it 'retruns true when list_type is set to label' do
+ subject.list_type = :label
+
+ expect(subject).to be_movable
+ end
+
+ it 'retruns false when list_type is set to backlog' do
+ subject.list_type = :backlog
+
+ expect(subject).not_to be_movable
+ end
+
+ it 'retruns false when list_type is set to done' do
+ subject.list_type = :done
+
+ expect(subject).not_to be_movable
+ end
+ end
+
+ describe '#title' do
+ it 'returns label name when list_type is set to label' do
+ subject.list_type = :label
+ subject.label = Label.new(name: 'Development')
+
+ expect(subject.title).to eq 'Development'
+ end
+
+ it 'returns Backlog when list_type is set to backlog' do
+ subject.list_type = :backlog
+
+ expect(subject.title).to eq 'Backlog'
+ end
+
+ it 'returns Done when list_type is set to done' do
+ subject.list_type = :done
+
+ expect(subject.title).to eq 'Done'
+ end
+ end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 9c3b4712cab..3e1dce6600c 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -23,6 +23,7 @@ describe Project, models: true do
it { is_expected.to have_one(:slack_service).dependent(:destroy) }
it { is_expected.to have_one(:pushover_service).dependent(:destroy) }
it { is_expected.to have_one(:asana_service).dependent(:destroy) }
+ it { is_expected.to have_one(:board).dependent(:destroy) }
it { is_expected.to have_many(:commit_statuses) }
it { is_expected.to have_many(:pipelines) }
it { is_expected.to have_many(:builds) }
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
new file mode 100644
index 00000000000..a1a4dd4c57c
--- /dev/null
+++ b/spec/services/boards/create_service_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe Boards::CreateService, services: true do
+ describe '#execute' do
+ subject(:service) { described_class.new(project, double) }
+
+ context 'when project does not have a board' do
+ let(:project) { create(:empty_project, board: nil) }
+
+ it 'creates a new board' do
+ expect { service.execute }.to change(Board, :count).by(1)
+ end
+
+ it 'creates default lists' do
+ service.execute
+
+ expect(project.board.lists.size).to eq 2
+ expect(project.board.lists.first).to be_backlog
+ expect(project.board.lists.last).to be_done
+ end
+ end
+
+ context 'when project has a board' do
+ let!(:project) { create(:project_with_board) }
+
+ it 'does not create a new board' do
+ expect { service.execute }.not_to change(Board, :count)
+ end
+
+ it 'does not create board lists' do
+ expect { service.execute }.not_to change(project.board.lists, :count)
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
new file mode 100644
index 00000000000..f7f45983d26
--- /dev/null
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+
+describe Boards::Issues::ListService, services: true do
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+ let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
+ let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
+ let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
+
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let!(:list1) { create(:list, board: board, label: development, position: 0) }
+ let!(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let!(:done) { create(:done_list, board: board) }
+
+ let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) }
+ let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) }
+ let!(:reopened_issue1) { create(:issue, :reopened, project: project) }
+
+ let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) }
+ let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) }
+ let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) }
+ let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) }
+
+ let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
+ let!(:closed_issue3) { create(:issue, :closed, project: project) }
+ let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ it 'delegates search to IssuesFinder' do
+ params = { id: list1.id }
+
+ expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
+
+ described_class.new(project, user, params).execute
+ end
+
+ context 'sets default order to priority' do
+ it 'returns opened issues when listing issues from Backlog' do
+ params = { id: backlog.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 = { 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 = { id: list1.id }
+
+ issues = described_class.new(project, user, params).execute
+
+ expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb
new file mode 100644
index 00000000000..0122159cab8
--- /dev/null
+++ b/spec/services/boards/issues/move_service_spec.rb
@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe Boards::Issues::MoveService, services: true do
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let!(:list1) { create(:list, board: board, label: development, position: 0) }
+ let!(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let!(:done) { create(:done_list, board: board) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'when moving from backlog' do
+ it 'adds the label of the list it goes to' do
+ issue = create(:labeled_issue, project: project, labels: [bug])
+ params = { from_list_id: backlog.id, to_list_id: list1.id }
+
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, development)
+ end
+ end
+
+ context 'when moving to backlog' do
+ it 'removes all list-labels' do
+ issue = create(:labeled_issue, project: project, labels: [bug, development, testing])
+ params = { from_list_id: list1.id, to_list_id: backlog.id }
+
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug)
+ end
+ end
+
+ context 'when moving from backlog to done' do
+ it 'closes the issue' do
+ issue = create(:labeled_issue, project: project, labels: [bug])
+ params = { from_list_id: backlog.id, to_list_id: done.id }
+
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_closed
+ end
+ end
+
+ context 'when moving an issue between lists' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:params) { { from_list_id: list1.id, to_list_id: list2.id } }
+
+ it 'delegates the label changes to Issues::UpdateService' do
+ expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
+
+ described_class.new(project, user, params).execute(issue)
+ end
+
+ it 'removes the label from the list it came from and adds the label of the list it goes to' do
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, testing)
+ end
+ end
+
+ context 'when moving to done' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing]) }
+ let(:params) { { from_list_id: list2.id, to_list_id: done.id } }
+
+ it 'delegates the close proceedings to Issues::CloseService' do
+ expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
+
+ described_class.new(project, user, params).execute(issue)
+ end
+
+ it 'removes all list-labels and close the issue' do
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_closed
+ end
+ end
+
+ context 'when moving from done' do
+ let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let(:params) { { from_list_id: done.id, to_list_id: list2.id } }
+
+ it 'delegates the re-open proceedings to Issues::ReopenService' do
+ expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
+
+ described_class.new(project, user, params).execute(issue)
+ end
+
+ it 'adds the label of the list it goes to and reopen the issue' do
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug, testing)
+ expect(issue).to be_reopened
+ end
+ end
+
+ context 'when moving from done to backlog' do
+ it 'reopens the issue' do
+ issue = create(:labeled_issue, :closed, project: project, labels: [bug])
+ params = { from_list_id: done.id, to_list_id: backlog.id }
+
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_reopened
+ end
+ end
+
+ context 'when moving to same list' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:params) { { from_list_id: list1.id, to_list_id: list1.id } }
+
+ it 'returns false' do
+ expect(described_class.new(project, user, params).execute(issue)).to eq false
+ end
+
+ it 'keeps issues labels' do
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, development)
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb
new file mode 100644
index 00000000000..5e7e145065e
--- /dev/null
+++ b/spec/services/boards/lists/create_service_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe Boards::Lists::CreateService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+ let(:label) { create(:label, name: 'in-progress') }
+
+ subject(:service) { described_class.new(project, user, label_id: label.id) }
+
+ context 'when board lists is empty' do
+ it 'creates a new list at beginning of the list' do
+ list = service.execute
+
+ expect(list.position).to eq 0
+ end
+ end
+
+ context 'when board lists has only a backlog list' do
+ it 'creates a new list at beginning of the list' do
+ create(:backlog_list, board: board)
+
+ list = service.execute
+
+ expect(list.position).to eq 0
+ end
+ end
+
+ context 'when board lists has only labels lists' do
+ it 'creates a new list at end of the lists' do
+ create(:list, board: board, position: 0)
+ create(:list, board: board, position: 1)
+
+ list = service.execute
+
+ expect(list.position).to eq 2
+ end
+ end
+
+ context 'when board lists has backlog, label and done lists' do
+ it 'creates a new list at end of the label lists' do
+ create(:backlog_list, board: board)
+ create(:done_list, board: board)
+ list1 = create(:list, board: board, position: 0)
+
+ list2 = service.execute
+
+ expect(list1.reload.position).to eq 0
+ expect(list2.reload.position).to eq 1
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb
new file mode 100644
index 00000000000..6eff445feee
--- /dev/null
+++ b/spec/services/boards/lists/destroy_service_spec.rb
@@ -0,0 +1,47 @@
+require 'spec_helper'
+
+describe Boards::Lists::DestroyService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+
+ context 'when list type is label' do
+ it 'removes list from board' do
+ list = create(:list, board: board)
+ service = described_class.new(project, user)
+
+ expect { service.execute(list) }.to change(board.lists, :count).by(-1)
+ end
+
+ it 'decrements position of higher lists' do
+ backlog = create(:backlog_list, board: board)
+ development = create(:list, board: board, position: 0)
+ review = create(:list, board: board, position: 1)
+ staging = create(:list, board: board, position: 2)
+ done = create(:done_list, board: board)
+
+ described_class.new(project, user).execute(development)
+
+ expect(backlog.reload.position).to be_nil
+ expect(review.reload.position).to eq 0
+ expect(staging.reload.position).to eq 1
+ expect(done.reload.position).to be_nil
+ end
+ end
+
+ it 'does not remove list from board when list type is backlog' do
+ list = create(:backlog_list, board: board)
+ service = described_class.new(project, user)
+
+ expect { service.execute(list) }.not_to change(board.lists, :count)
+ end
+
+ it 'does not remove list from board when list type is done' do
+ list = create(:done_list, board: board)
+ service = described_class.new(project, user)
+
+ expect { service.execute(list) }.not_to change(board.lists, :count)
+ end
+ end
+end
diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb
new file mode 100644
index 00000000000..9fd39122737
--- /dev/null
+++ b/spec/services/boards/lists/generate_service_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Boards::Lists::GenerateService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+
+ subject(:service) { described_class.new(project, user) }
+
+ context 'when board lists is empty' do
+ it 'creates the default lists' do
+ expect { service.execute }.to change(board.lists, :count).by(4)
+ end
+ end
+
+ context 'when board lists is not empty' do
+ it 'does not creates the default lists' do
+ create(:list, board: board)
+
+ expect { service.execute }.not_to change(board.lists, :count)
+ end
+ end
+
+ context 'when project labels does not contains any list label' do
+ it 'creates labels' do
+ expect { service.execute }.to change(project.labels, :count).by(4)
+ end
+ end
+
+ context 'when project labels contains some of list label' do
+ it 'creates the missing labels' do
+ create(:label, project: project, name: 'Development')
+ create(:label, project: project, name: 'Ready')
+
+ expect { service.execute }.to change(project.labels, :count).by(2)
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb
new file mode 100644
index 00000000000..3e9b7d07fc6
--- /dev/null
+++ b/spec/services/boards/lists/move_service_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe Boards::Lists::MoveService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let!(:planning) { create(:list, board: board, position: 0) }
+ let!(:development) { create(:list, board: board, position: 1) }
+ let!(:review) { create(:list, board: board, position: 2) }
+ let!(:staging) { create(:list, board: board, position: 3) }
+ let!(:done) { create(:done_list, board: board) }
+
+ context 'when list type is set to label' do
+ it 'keeps position of lists when new position is nil' do
+ service = described_class.new(project, user, position: nil)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is equal to old position' do
+ service = described_class.new(project, user, position: planning.position)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is negative' do
+ service = described_class.new(project, user, position: -1)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is equal to number of labels lists' do
+ service = described_class.new(project, user, position: board.lists.label.size)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is greater than number of labels lists' do
+ service = described_class.new(project, user, position: board.lists.label.size + 1)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'increments position of intermediate lists when new positon is equal to first position' do
+ service = described_class.new(project, user, position: 0)
+
+ service.execute(staging)
+
+ expect(current_list_positions).to eq [1, 2, 3, 0]
+ end
+
+ it 'decrements position of intermediate lists when new positon is equal to last position' do
+ service = described_class.new(project, user, position: board.lists.label.last.position)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [3, 0, 1, 2]
+ end
+
+ it 'decrements position of intermediate lists when new position is greater than old position' do
+ service = described_class.new(project, user, position: 2)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [2, 0, 1, 3]
+ end
+
+ it 'increments position of intermediate lists when new position is lower than old position' do
+ service = described_class.new(project, user, position: 1)
+
+ service.execute(staging)
+
+ expect(current_list_positions).to eq [0, 2, 3, 1]
+ end
+ end
+
+ it 'keeps position of lists when list type is backlog' do
+ service = described_class.new(project, user, position: 2)
+
+ service.execute(backlog)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when list type is done' do
+ service = described_class.new(project, user, position: 2)
+
+ service.execute(done)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+ end
+
+ def current_list_positions
+ [planning, development, review, staging].map { |list| list.reload.position }
+ end
+end
diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb
new file mode 100644
index 00000000000..e42d727672b
--- /dev/null
+++ b/spec/support/api/schema_matcher.rb
@@ -0,0 +1,8 @@
+RSpec::Matchers.define :match_response_schema do |schema, **options|
+ match do |response|
+ schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas"
+ schema_path = "#{schema_directory}/#{schema}.json"
+
+ JSON::Validator.validate!(schema_path, response.body, options)
+ end
+end
diff --git a/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js
new file mode 100644
index 00000000000..eca7c5012b2
--- /dev/null
+++ b/vendor/assets/javascripts/Sortable.js
@@ -0,0 +1,1285 @@
+/**!
+ * Sortable
+ * @author RubaXa <trash@rubaxa.org>
+ * @license MIT
+ */
+
+
+(function (factory) {
+ "use strict";
+
+ if (typeof define === "function" && define.amd) {
+ define(factory);
+ }
+ else if (typeof module != "undefined" && typeof module.exports != "undefined") {
+ module.exports = factory();
+ }
+ else if (typeof Package !== "undefined") {
+ Sortable = factory(); // export for Meteor.js
+ }
+ else {
+ /* jshint sub:true */
+ window["Sortable"] = factory();
+ }
+})(function () {
+ "use strict";
+
+ var dragEl,
+ parentEl,
+ ghostEl,
+ cloneEl,
+ rootEl,
+ nextEl,
+
+ scrollEl,
+ scrollParentEl,
+
+ lastEl,
+ lastCSS,
+ lastParentCSS,
+
+ oldIndex,
+ newIndex,
+
+ activeGroup,
+ autoScroll = {},
+
+ tapEvt,
+ touchEvt,
+
+ moved,
+
+ /** @const */
+ RSPACE = /\s+/g,
+
+ expando = 'Sortable' + (new Date).getTime(),
+
+ win = window,
+ document = win.document,
+ parseInt = win.parseInt,
+
+ supportDraggable = !!('draggable' in document.createElement('div')),
+ supportCssPointerEvents = (function (el) {
+ el = document.createElement('x');
+ el.style.cssText = 'pointer-events:auto';
+ return el.style.pointerEvents === 'auto';
+ })(),
+
+ _silent = false,
+
+ abs = Math.abs,
+ min = Math.min,
+ slice = [].slice,
+
+ touchDragOverListeners = [],
+
+ _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) {
+ // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521
+ if (rootEl && options.scroll) {
+ var el,
+ rect,
+ sens = options.scrollSensitivity,
+ speed = options.scrollSpeed,
+
+ x = evt.clientX,
+ y = evt.clientY,
+
+ winWidth = window.innerWidth,
+ winHeight = window.innerHeight,
+
+ vx,
+ vy
+ ;
+
+ // Delect scrollEl
+ if (scrollParentEl !== rootEl) {
+ scrollEl = options.scroll;
+ scrollParentEl = rootEl;
+
+ if (scrollEl === true) {
+ scrollEl = rootEl;
+
+ do {
+ if ((scrollEl.offsetWidth < scrollEl.scrollWidth) ||
+ (scrollEl.offsetHeight < scrollEl.scrollHeight)
+ ) {
+ break;
+ }
+ /* jshint boss:true */
+ } while (scrollEl = scrollEl.parentNode);
+ }
+ }
+
+ if (scrollEl) {
+ el = scrollEl;
+ rect = scrollEl.getBoundingClientRect();
+ vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens);
+ vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens);
+ }
+
+
+ if (!(vx || vy)) {
+ vx = (winWidth - x <= sens) - (x <= sens);
+ vy = (winHeight - y <= sens) - (y <= sens);
+
+ /* jshint expr:true */
+ (vx || vy) && (el = win);
+ }
+
+
+ if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) {
+ autoScroll.el = el;
+ autoScroll.vx = vx;
+ autoScroll.vy = vy;
+
+ clearInterval(autoScroll.pid);
+
+ if (el) {
+ autoScroll.pid = setInterval(function () {
+ if (el === win) {
+ win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed);
+ } else {
+ vy && (el.scrollTop += vy * speed);
+ vx && (el.scrollLeft += vx * speed);
+ }
+ }, 24);
+ }
+ }
+ }
+ }, 30),
+
+ _prepareGroup = function (options) {
+ var group = options.group;
+
+ if (!group || typeof group != 'object') {
+ group = options.group = {name: group};
+ }
+
+ ['pull', 'put'].forEach(function (key) {
+ if (!(key in group)) {
+ group[key] = true;
+ }
+ });
+
+ options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' ';
+ }
+ ;
+
+
+
+ /**
+ * @class Sortable
+ * @param {HTMLElement} el
+ * @param {Object} [options]
+ */
+ function Sortable(el, options) {
+ if (!(el && el.nodeType && el.nodeType === 1)) {
+ throw 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el);
+ }
+
+ this.el = el; // root element
+ this.options = options = _extend({}, options);
+
+
+ // Export instance
+ el[expando] = this;
+
+
+ // Default options
+ var defaults = {
+ group: Math.random(),
+ sort: true,
+ disabled: false,
+ store: null,
+ handle: null,
+ scroll: true,
+ scrollSensitivity: 30,
+ scrollSpeed: 10,
+ draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*',
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ ignore: 'a, img',
+ filter: null,
+ animation: 0,
+ setData: function (dataTransfer, dragEl) {
+ dataTransfer.setData('Text', dragEl.textContent);
+ },
+ dropBubble: false,
+ dragoverBubble: false,
+ dataIdAttr: 'data-id',
+ delay: 0,
+ forceFallback: false,
+ fallbackClass: 'sortable-fallback',
+ fallbackOnBody: false,
+ fallbackTolerance: 0
+ };
+
+
+ // Set default options
+ for (var name in defaults) {
+ !(name in options) && (options[name] = defaults[name]);
+ }
+
+ _prepareGroup(options);
+
+ // Bind all private methods
+ for (var fn in this) {
+ if (fn.charAt(0) === '_') {
+ this[fn] = this[fn].bind(this);
+ }
+ }
+
+ // Setup drag mode
+ this.nativeDraggable = options.forceFallback ? false : supportDraggable;
+
+ // Bind events
+ _on(el, 'mousedown', this._onTapStart);
+ _on(el, 'touchstart', this._onTapStart);
+
+ if (this.nativeDraggable) {
+ _on(el, 'dragover', this);
+ _on(el, 'dragenter', this);
+ }
+
+ touchDragOverListeners.push(this._onDragOver);
+
+ // Restore sorting
+ options.store && this.sort(options.store.get(this));
+ }
+
+
+ Sortable.prototype = /** @lends Sortable.prototype */ {
+ constructor: Sortable,
+
+ _onTapStart: function (/** Event|TouchEvent */evt) {
+ var _this = this,
+ el = this.el,
+ options = this.options,
+ type = evt.type,
+ touch = evt.touches && evt.touches[0],
+ target = (touch || evt).target,
+ originalTarget = target,
+ filter = options.filter,
+ startIndex;
+
+ // Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group.
+ if (dragEl) {
+ return;
+ }
+
+ if (type === 'mousedown' && evt.button !== 0 || options.disabled) {
+ return; // only left button or enabled
+ }
+
+ target = _closest(target, options.draggable, el);
+
+ if (!target) {
+ return;
+ }
+
+ if (options.handle && !_closest(originalTarget, options.handle, el)) {
+ return;
+ }
+
+ // Get the index of the dragged element within its parent
+ startIndex = _index(target, options.draggable);
+
+ // Check filter
+ if (typeof filter === 'function') {
+ if (filter.call(this, evt, target, this)) {
+ _dispatchEvent(_this, originalTarget, 'filter', target, el, startIndex);
+ evt.preventDefault();
+ return; // cancel dnd
+ }
+ }
+ else if (filter) {
+ filter = filter.split(',').some(function (criteria) {
+ criteria = _closest(originalTarget, criteria.trim(), el);
+
+ if (criteria) {
+ _dispatchEvent(_this, criteria, 'filter', target, el, startIndex);
+ return true;
+ }
+ });
+
+ if (filter) {
+ evt.preventDefault();
+ return; // cancel dnd
+ }
+ }
+
+ // Prepare `dragstart`
+ this._prepareDragStart(evt, touch, target, startIndex);
+ },
+
+ _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) {
+ var _this = this,
+ el = _this.el,
+ options = _this.options,
+ ownerDocument = el.ownerDocument,
+ dragStartFn;
+
+ if (target && !dragEl && (target.parentNode === el)) {
+ tapEvt = evt;
+
+ rootEl = el;
+ dragEl = target;
+ parentEl = dragEl.parentNode;
+ nextEl = dragEl.nextSibling;
+ activeGroup = options.group;
+ oldIndex = startIndex;
+
+ this._lastX = (touch || evt).clientX;
+ this._lastY = (touch || evt).clientY;
+
+ dragStartFn = function () {
+ // Delayed drag has been triggered
+ // we can re-enable the events: touchmove/mousemove
+ _this._disableDelayedDrag();
+
+ // Make the element draggable
+ dragEl.draggable = true;
+
+ // Chosen item
+ _toggleClass(dragEl, _this.options.chosenClass, true);
+
+ // Bind the events: dragstart/dragend
+ _this._triggerDragStart(touch);
+
+ // Drag start event
+ _dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, oldIndex);
+ };
+
+ // Disable "draggable"
+ options.ignore.split(',').forEach(function (criteria) {
+ _find(dragEl, criteria.trim(), _disableDraggable);
+ });
+
+ _on(ownerDocument, 'mouseup', _this._onDrop);
+ _on(ownerDocument, 'touchend', _this._onDrop);
+ _on(ownerDocument, 'touchcancel', _this._onDrop);
+
+ if (options.delay) {
+ // If the user moves the pointer or let go the click or touch
+ // before the delay has been reached:
+ // disable the delayed drag
+ _on(ownerDocument, 'mouseup', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchend', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchcancel', _this._disableDelayedDrag);
+ _on(ownerDocument, 'mousemove', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchmove', _this._disableDelayedDrag);
+
+ _this._dragStartTimer = setTimeout(dragStartFn, options.delay);
+ } else {
+ dragStartFn();
+ }
+ }
+ },
+
+ _disableDelayedDrag: function () {
+ var ownerDocument = this.el.ownerDocument;
+
+ clearTimeout(this._dragStartTimer);
+ _off(ownerDocument, 'mouseup', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchend', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchcancel', this._disableDelayedDrag);
+ _off(ownerDocument, 'mousemove', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchmove', this._disableDelayedDrag);
+ },
+
+ _triggerDragStart: function (/** Touch */touch) {
+ if (touch) {
+ // Touch device support
+ tapEvt = {
+ target: dragEl,
+ clientX: touch.clientX,
+ clientY: touch.clientY
+ };
+
+ this._onDragStart(tapEvt, 'touch');
+ }
+ else if (!this.nativeDraggable) {
+ this._onDragStart(tapEvt, true);
+ }
+ else {
+ _on(dragEl, 'dragend', this);
+ _on(rootEl, 'dragstart', this._onDragStart);
+ }
+
+ try {
+ if (document.selection) {
+ document.selection.empty();
+ } else {
+ window.getSelection().removeAllRanges();
+ }
+ } catch (err) {
+ }
+ },
+
+ _dragStarted: function () {
+ if (rootEl && dragEl) {
+ // Apply effect
+ _toggleClass(dragEl, this.options.ghostClass, true);
+
+ Sortable.active = this;
+
+ // Drag start event
+ _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex);
+ }
+ },
+
+ _emulateDragOver: function () {
+ if (touchEvt) {
+ if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) {
+ return;
+ }
+
+ this._lastX = touchEvt.clientX;
+ this._lastY = touchEvt.clientY;
+
+ if (!supportCssPointerEvents) {
+ _css(ghostEl, 'display', 'none');
+ }
+
+ var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY),
+ parent = target,
+ groupName = ' ' + this.options.group.name + '',
+ i = touchDragOverListeners.length;
+
+ if (parent) {
+ do {
+ if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) {
+ while (i--) {
+ touchDragOverListeners[i]({
+ clientX: touchEvt.clientX,
+ clientY: touchEvt.clientY,
+ target: target,
+ rootEl: parent
+ });
+ }
+
+ break;
+ }
+
+ target = parent; // store last element
+ }
+ /* jshint boss:true */
+ while (parent = parent.parentNode);
+ }
+
+ if (!supportCssPointerEvents) {
+ _css(ghostEl, 'display', '');
+ }
+ }
+ },
+
+
+ _onTouchMove: function (/**TouchEvent*/evt) {
+ if (tapEvt) {
+ var options = this.options,
+ fallbackTolerance = options.fallbackTolerance,
+ touch = evt.touches ? evt.touches[0] : evt,
+ dx = touch.clientX - tapEvt.clientX,
+ dy = touch.clientY - tapEvt.clientY,
+ translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)';
+
+ // only set the status to dragging, when we are actually dragging
+ if (!Sortable.active) {
+ if (fallbackTolerance &&
+ min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance
+ ) {
+ return;
+ }
+
+ this._dragStarted();
+ }
+
+ // as well as creating the ghost element on the document body
+ this._appendGhost();
+
+ moved = true;
+ touchEvt = touch;
+
+ _css(ghostEl, 'webkitTransform', translate3d);
+ _css(ghostEl, 'mozTransform', translate3d);
+ _css(ghostEl, 'msTransform', translate3d);
+ _css(ghostEl, 'transform', translate3d);
+
+ evt.preventDefault();
+ }
+ },
+
+ _appendGhost: function () {
+ if (!ghostEl) {
+ var rect = dragEl.getBoundingClientRect(),
+ css = _css(dragEl),
+ options = this.options,
+ ghostRect;
+
+ ghostEl = dragEl.cloneNode(true);
+
+ _toggleClass(ghostEl, options.ghostClass, false);
+ _toggleClass(ghostEl, options.fallbackClass, true);
+
+ _css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10));
+ _css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10));
+ _css(ghostEl, 'width', rect.width);
+ _css(ghostEl, 'height', rect.height);
+ _css(ghostEl, 'opacity', '0.8');
+ _css(ghostEl, 'position', 'fixed');
+ _css(ghostEl, 'zIndex', '100000');
+ _css(ghostEl, 'pointerEvents', 'none');
+
+ options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl);
+
+ // Fixing dimensions.
+ ghostRect = ghostEl.getBoundingClientRect();
+ _css(ghostEl, 'width', rect.width * 2 - ghostRect.width);
+ _css(ghostEl, 'height', rect.height * 2 - ghostRect.height);
+ }
+ },
+
+ _onDragStart: function (/**Event*/evt, /**boolean*/useFallback) {
+ var dataTransfer = evt.dataTransfer,
+ options = this.options;
+
+ this._offUpEvents();
+
+ if (activeGroup.pull == 'clone') {
+ cloneEl = dragEl.cloneNode(true);
+ _css(cloneEl, 'display', 'none');
+ rootEl.insertBefore(cloneEl, dragEl);
+ _dispatchEvent(this, rootEl, 'clone', dragEl);
+ }
+
+ if (useFallback) {
+ if (useFallback === 'touch') {
+ // Bind touch events
+ _on(document, 'touchmove', this._onTouchMove);
+ _on(document, 'touchend', this._onDrop);
+ _on(document, 'touchcancel', this._onDrop);
+ } else {
+ // Old brwoser
+ _on(document, 'mousemove', this._onTouchMove);
+ _on(document, 'mouseup', this._onDrop);
+ }
+
+ this._loopId = setInterval(this._emulateDragOver, 50);
+ }
+ else {
+ if (dataTransfer) {
+ dataTransfer.effectAllowed = 'move';
+ options.setData && options.setData.call(this, dataTransfer, dragEl);
+ }
+
+ _on(document, 'drop', this);
+ setTimeout(this._dragStarted, 0);
+ }
+ },
+
+ _onDragOver: function (/**Event*/evt) {
+ var el = this.el,
+ target,
+ dragRect,
+ revert,
+ options = this.options,
+ group = options.group,
+ groupPut = group.put,
+ isOwner = (activeGroup === group),
+ canSort = options.sort;
+
+ if (evt.preventDefault !== void 0) {
+ evt.preventDefault();
+ !options.dragoverBubble && evt.stopPropagation();
+ }
+
+ moved = true;
+
+ if (activeGroup && !options.disabled &&
+ (isOwner
+ ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list
+ : activeGroup.pull && groupPut && (
+ (activeGroup.name === group.name) || // by Name
+ (groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array
+ )
+ ) &&
+ (evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback
+ ) {
+ // Smart auto-scrolling
+ _autoScroll(evt, options, this.el);
+
+ if (_silent) {
+ return;
+ }
+
+ target = _closest(evt.target, options.draggable, el);
+ dragRect = dragEl.getBoundingClientRect();
+
+ if (revert) {
+ _cloneHide(true);
+ parentEl = rootEl; // actualization
+
+ if (cloneEl || nextEl) {
+ rootEl.insertBefore(dragEl, cloneEl || nextEl);
+ }
+ else if (!canSort) {
+ rootEl.appendChild(dragEl);
+ }
+
+ return;
+ }
+
+
+ if ((el.children.length === 0) || (el.children[0] === ghostEl) ||
+ (el === evt.target) && (target = _ghostIsLast(el, evt))
+ ) {
+
+ if (target) {
+ if (target.animated) {
+ return;
+ }
+
+ targetRect = target.getBoundingClientRect();
+ }
+
+ _cloneHide(isOwner);
+
+ if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) {
+ if (!dragEl.contains(el)) {
+ el.appendChild(dragEl);
+ parentEl = el; // actualization
+ }
+
+ this._animate(dragRect, dragEl);
+ target && this._animate(targetRect, target);
+ }
+ }
+ else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) {
+ if (lastEl !== target) {
+ lastEl = target;
+ lastCSS = _css(target);
+ lastParentCSS = _css(target.parentNode);
+ }
+
+
+ var targetRect = target.getBoundingClientRect(),
+ width = targetRect.right - targetRect.left,
+ height = targetRect.bottom - targetRect.top,
+ floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display)
+ || (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0),
+ isWide = (target.offsetWidth > dragEl.offsetWidth),
+ isLong = (target.offsetHeight > dragEl.offsetHeight),
+ halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5,
+ nextSibling = target.nextElementSibling,
+ moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect),
+ after
+ ;
+
+ if (moveVector !== false) {
+ _silent = true;
+ setTimeout(_unsilent, 30);
+
+ _cloneHide(isOwner);
+
+ if (moveVector === 1 || moveVector === -1) {
+ after = (moveVector === 1);
+ }
+ else if (floating) {
+ var elTop = dragEl.offsetTop,
+ tgTop = target.offsetTop;
+
+ if (elTop === tgTop) {
+ after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide;
+ }
+ else if (target.previousElementSibling === dragEl || dragEl.previousElementSibling === target) {
+ after = (evt.clientY - targetRect.top) / height > 0.5;
+ } else {
+ after = tgTop > elTop;
+ }
+ } else {
+ after = (nextSibling !== dragEl) && !isLong || halfway && isLong;
+ }
+
+ if (!dragEl.contains(el)) {
+ if (after && !nextSibling) {
+ el.appendChild(dragEl);
+ } else {
+ target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
+ }
+ }
+
+ parentEl = dragEl.parentNode; // actualization
+
+ this._animate(dragRect, dragEl);
+ this._animate(targetRect, target);
+ }
+ }
+ }
+ },
+
+ _animate: function (prevRect, target) {
+ var ms = this.options.animation;
+
+ if (ms) {
+ var currentRect = target.getBoundingClientRect();
+
+ _css(target, 'transition', 'none');
+ _css(target, 'transform', 'translate3d('
+ + (prevRect.left - currentRect.left) + 'px,'
+ + (prevRect.top - currentRect.top) + 'px,0)'
+ );
+
+ target.offsetWidth; // repaint
+
+ _css(target, 'transition', 'all ' + ms + 'ms');
+ _css(target, 'transform', 'translate3d(0,0,0)');
+
+ clearTimeout(target.animated);
+ target.animated = setTimeout(function () {
+ _css(target, 'transition', '');
+ _css(target, 'transform', '');
+ target.animated = false;
+ }, ms);
+ }
+ },
+
+ _offUpEvents: function () {
+ var ownerDocument = this.el.ownerDocument;
+
+ _off(document, 'touchmove', this._onTouchMove);
+ _off(ownerDocument, 'mouseup', this._onDrop);
+ _off(ownerDocument, 'touchend', this._onDrop);
+ _off(ownerDocument, 'touchcancel', this._onDrop);
+ },
+
+ _onDrop: function (/**Event*/evt) {
+ var el = this.el,
+ options = this.options;
+
+ clearInterval(this._loopId);
+ clearInterval(autoScroll.pid);
+ clearTimeout(this._dragStartTimer);
+
+ // Unbind events
+ _off(document, 'mousemove', this._onTouchMove);
+
+ if (this.nativeDraggable) {
+ _off(document, 'drop', this);
+ _off(el, 'dragstart', this._onDragStart);
+ }
+
+ this._offUpEvents();
+
+ if (evt) {
+ if (moved) {
+ evt.preventDefault();
+ !options.dropBubble && evt.stopPropagation();
+ }
+
+ ghostEl && ghostEl.parentNode.removeChild(ghostEl);
+
+ if (dragEl) {
+ if (this.nativeDraggable) {
+ _off(dragEl, 'dragend', this);
+ }
+
+ _disableDraggable(dragEl);
+
+ // Remove class's
+ _toggleClass(dragEl, this.options.ghostClass, false);
+ _toggleClass(dragEl, this.options.chosenClass, false);
+
+ if (rootEl !== parentEl) {
+ newIndex = _index(dragEl, options.draggable);
+
+ if (newIndex >= 0) {
+ // drag from one list and drop into another
+ _dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+ _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+
+ // Add event
+ _dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex);
+
+ // Remove event
+ _dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex);
+ }
+ }
+ else {
+ // Remove clone
+ cloneEl && cloneEl.parentNode.removeChild(cloneEl);
+
+ if (dragEl.nextSibling !== nextEl) {
+ // Get the index of the dragged element within its parent
+ newIndex = _index(dragEl, options.draggable);
+
+ if (newIndex >= 0) {
+ // drag & drop within the same list
+ _dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex);
+ _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+ }
+ }
+ }
+
+ if (Sortable.active) {
+ if (newIndex === null || newIndex === -1) {
+ newIndex = oldIndex;
+ }
+
+ _dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex);
+
+ // Save sorting
+ this.save();
+ }
+ }
+
+ }
+
+ this._nulling();
+ },
+
+ _nulling: function () {
+ rootEl =
+ dragEl =
+ parentEl =
+ ghostEl =
+ nextEl =
+ cloneEl =
+
+ scrollEl =
+ scrollParentEl =
+
+ tapEvt =
+ touchEvt =
+
+ moved =
+ newIndex =
+
+ lastEl =
+ lastCSS =
+
+ activeGroup =
+ Sortable.active = null;
+ },
+
+ handleEvent: function (/**Event*/evt) {
+ var type = evt.type;
+
+ if (type === 'dragover' || type === 'dragenter') {
+ if (dragEl) {
+ this._onDragOver(evt);
+ _globalDragOver(evt);
+ }
+ }
+ else if (type === 'drop' || type === 'dragend') {
+ this._onDrop(evt);
+ }
+ },
+
+
+ /**
+ * Serializes the item into an array of string.
+ * @returns {String[]}
+ */
+ toArray: function () {
+ var order = [],
+ el,
+ children = this.el.children,
+ i = 0,
+ n = children.length,
+ options = this.options;
+
+ for (; i < n; i++) {
+ el = children[i];
+ if (_closest(el, options.draggable, this.el)) {
+ order.push(el.getAttribute(options.dataIdAttr) || _generateId(el));
+ }
+ }
+
+ return order;
+ },
+
+
+ /**
+ * Sorts the elements according to the array.
+ * @param {String[]} order order of the items
+ */
+ sort: function (order) {
+ var items = {}, rootEl = this.el;
+
+ this.toArray().forEach(function (id, i) {
+ var el = rootEl.children[i];
+
+ if (_closest(el, this.options.draggable, rootEl)) {
+ items[id] = el;
+ }
+ }, this);
+
+ order.forEach(function (id) {
+ if (items[id]) {
+ rootEl.removeChild(items[id]);
+ rootEl.appendChild(items[id]);
+ }
+ });
+ },
+
+
+ /**
+ * Save the current sorting
+ */
+ save: function () {
+ var store = this.options.store;
+ store && store.set(this);
+ },
+
+
+ /**
+ * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
+ * @param {HTMLElement} el
+ * @param {String} [selector] default: `options.draggable`
+ * @returns {HTMLElement|null}
+ */
+ closest: function (el, selector) {
+ return _closest(el, selector || this.options.draggable, this.el);
+ },
+
+
+ /**
+ * Set/get option
+ * @param {string} name
+ * @param {*} [value]
+ * @returns {*}
+ */
+ option: function (name, value) {
+ var options = this.options;
+
+ if (value === void 0) {
+ return options[name];
+ } else {
+ options[name] = value;
+
+ if (name === 'group') {
+ _prepareGroup(options);
+ }
+ }
+ },
+
+
+ /**
+ * Destroy
+ */
+ destroy: function () {
+ var el = this.el;
+
+ el[expando] = null;
+
+ _off(el, 'mousedown', this._onTapStart);
+ _off(el, 'touchstart', this._onTapStart);
+
+ if (this.nativeDraggable) {
+ _off(el, 'dragover', this);
+ _off(el, 'dragenter', this);
+ }
+
+ // Remove draggable attributes
+ Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) {
+ el.removeAttribute('draggable');
+ });
+
+ touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1);
+
+ this._onDrop();
+
+ this.el = el = null;
+ }
+ };
+
+
+ function _cloneHide(state) {
+ if (cloneEl && (cloneEl.state !== state)) {
+ _css(cloneEl, 'display', state ? 'none' : '');
+ !state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl);
+ cloneEl.state = state;
+ }
+ }
+
+
+ function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) {
+ if (el) {
+ ctx = ctx || document;
+
+ do {
+ if ((selector === '>*' && el.parentNode === ctx) || _matches(el, selector)) {
+ return el;
+ }
+ }
+ while (el !== ctx && (el = el.parentNode));
+ }
+
+ return null;
+ }
+
+
+ function _globalDragOver(/**Event*/evt) {
+ if (evt.dataTransfer) {
+ evt.dataTransfer.dropEffect = 'move';
+ }
+ evt.preventDefault();
+ }
+
+
+ function _on(el, event, fn) {
+ el.addEventListener(event, fn, false);
+ }
+
+
+ function _off(el, event, fn) {
+ el.removeEventListener(event, fn, false);
+ }
+
+
+ function _toggleClass(el, name, state) {
+ if (el) {
+ if (el.classList) {
+ el.classList[state ? 'add' : 'remove'](name);
+ }
+ else {
+ var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' ');
+ el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' ');
+ }
+ }
+ }
+
+
+ function _css(el, prop, val) {
+ var style = el && el.style;
+
+ if (style) {
+ if (val === void 0) {
+ if (document.defaultView && document.defaultView.getComputedStyle) {
+ val = document.defaultView.getComputedStyle(el, '');
+ }
+ else if (el.currentStyle) {
+ val = el.currentStyle;
+ }
+
+ return prop === void 0 ? val : val[prop];
+ }
+ else {
+ if (!(prop in style)) {
+ prop = '-webkit-' + prop;
+ }
+
+ style[prop] = val + (typeof val === 'string' ? '' : 'px');
+ }
+ }
+ }
+
+
+ function _find(ctx, tagName, iterator) {
+ if (ctx) {
+ var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;
+
+ if (iterator) {
+ for (; i < n; i++) {
+ iterator(list[i], i);
+ }
+ }
+
+ return list;
+ }
+
+ return [];
+ }
+
+
+
+ function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) {
+ var evt = document.createEvent('Event'),
+ options = (sortable || rootEl[expando]).options,
+ onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);
+
+ evt.initEvent(name, true, true);
+
+ evt.to = rootEl;
+ evt.from = fromEl || rootEl;
+ evt.item = targetEl || rootEl;
+ evt.clone = cloneEl;
+
+ evt.oldIndex = startIndex;
+ evt.newIndex = newIndex;
+
+ rootEl.dispatchEvent(evt);
+
+ if (options[onName]) {
+ options[onName].call(sortable, evt);
+ }
+ }
+
+
+ function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) {
+ var evt,
+ sortable = fromEl[expando],
+ onMoveFn = sortable.options.onMove,
+ retVal;
+
+ evt = document.createEvent('Event');
+ evt.initEvent('move', true, true);
+
+ evt.to = toEl;
+ evt.from = fromEl;
+ evt.dragged = dragEl;
+ evt.draggedRect = dragRect;
+ evt.related = targetEl || toEl;
+ evt.relatedRect = targetRect || toEl.getBoundingClientRect();
+
+ fromEl.dispatchEvent(evt);
+
+ if (onMoveFn) {
+ retVal = onMoveFn.call(sortable, evt);
+ }
+
+ return retVal;
+ }
+
+
+ function _disableDraggable(el) {
+ el.draggable = false;
+ }
+
+
+ function _unsilent() {
+ _silent = false;
+ }
+
+
+ /** @returns {HTMLElement|false} */
+ function _ghostIsLast(el, evt) {
+ var lastEl = el.lastElementChild,
+ rect = lastEl.getBoundingClientRect();
+
+ return ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta
+ }
+
+
+ /**
+ * Generate id
+ * @param {HTMLElement} el
+ * @returns {String}
+ * @private
+ */
+ function _generateId(el) {
+ var str = el.tagName + el.className + el.src + el.href + el.textContent,
+ i = str.length,
+ sum = 0;
+
+ while (i--) {
+ sum += str.charCodeAt(i);
+ }
+
+ return sum.toString(36);
+ }
+
+ /**
+ * Returns the index of an element within its parent for a selected set of
+ * elements
+ * @param {HTMLElement} el
+ * @param {selector} selector
+ * @return {number}
+ */
+ function _index(el, selector) {
+ var index = 0;
+
+ if (!el || !el.parentNode) {
+ return -1;
+ }
+
+ while (el && (el = el.previousElementSibling)) {
+ if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && (selector === '>*' || _matches(el, selector))) {
+ index++;
+ }
+ }
+
+ return index;
+ }
+
+ function _matches(/**HTMLElement*/el, /**String*/selector) {
+ if (el) {
+ selector = selector.split('.');
+
+ var tag = selector.shift().toUpperCase(),
+ re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g');
+
+ return (
+ (tag === '' || el.nodeName.toUpperCase() == tag) &&
+ (!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length)
+ );
+ }
+
+ return false;
+ }
+
+ function _throttle(callback, ms) {
+ var args, _this;
+
+ return function () {
+ if (args === void 0) {
+ args = arguments;
+ _this = this;
+
+ setTimeout(function () {
+ if (args.length === 1) {
+ callback.call(_this, args[0]);
+ } else {
+ callback.apply(_this, args);
+ }
+
+ args = void 0;
+ }, ms);
+ }
+ };
+ }
+
+ function _extend(dst, src) {
+ if (dst && src) {
+ for (var key in src) {
+ if (src.hasOwnProperty(key)) {
+ dst[key] = src[key];
+ }
+ }
+ }
+
+ return dst;
+ }
+
+
+ // Export utils
+ Sortable.utils = {
+ on: _on,
+ off: _off,
+ css: _css,
+ find: _find,
+ is: function (el, selector) {
+ return !!_closest(el, selector, el);
+ },
+ extend: _extend,
+ throttle: _throttle,
+ closest: _closest,
+ toggleClass: _toggleClass,
+ index: _index
+ };
+
+
+ /**
+ * Create sortable instance
+ * @param {HTMLElement} el
+ * @param {Object} [options]
+ */
+ Sortable.create = function (el, options) {
+ return new Sortable(el, options);
+ };
+
+
+ // Export
+ Sortable.version = '1.4.2';
+ return Sortable;
+});
diff --git a/vendor/assets/javascripts/clipboard.js b/vendor/assets/javascripts/clipboard.js
index 1b1f4f0bd63..39d7d2306f8 100644
--- a/vendor/assets/javascripts/clipboard.js
+++ b/vendor/assets/javascripts/clipboard.js
@@ -154,12 +154,12 @@ function E () {
E.prototype = {
on: function (name, callback, ctx) {
var e = this.e || (this.e = {});
-
+
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
-
+
return this;
},
@@ -169,7 +169,7 @@ E.prototype = {
self.off(name, fn);
callback.apply(ctx, arguments);
};
-
+
return this.on(name, fn, ctx);
},
@@ -178,11 +178,11 @@ E.prototype = {
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
-
+
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
-
+
return this;
},
@@ -190,21 +190,21 @@ E.prototype = {
var e = this.e || (this.e = {});
var evts = e[name];
var liveEvents = [];
-
+
if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback) liveEvents.push(evts[i]);
}
}
-
+
// Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
- (liveEvents.length)
+ (liveEvents.length)
? e[name] = liveEvents
: delete e[name];
-
+
return this;
}
};
@@ -618,4 +618,4 @@ exports['default'] = Clipboard;
module.exports = exports['default'];
},{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7)
-}); \ No newline at end of file
+});