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 0000000000000000000000000000000000000000..2c65d4427bee59b5b3746f2144183b0a70840f14 --- /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 0000000000000000000000000000000000000000..e17784e7948038aa9e09c34e16c03d720d67a535 --- /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 0000000000000000000000000000000000000000..63d72d857d9c496463cd8aaa1b235369e9c903fc --- /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 0000000000000000000000000000000000000000..4a7cfeaeab22e29c46982ac1129f6eba76835aa1 --- /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(``); + } 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 0000000000000000000000000000000000000000..34653cd48ef190289643cc651de33c3a9fa13eca --- /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 0000000000000000000000000000000000000000..1503d14c50802df5e6178f0c08e9abfa532ce2f4 --- /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 0000000000000000000000000000000000000000..1a4d81579700ae7e82ec08f268eaf5e66f9c143d --- /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 = $('
  • '), + $a = $('', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }), + $labelColor = $('', { + 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 0000000000000000000000000000000000000000..b7afe4897b6b53c0e4f7936af7a609882affc9b6 --- /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 0000000000000000000000000000000000000000..eb082103de9ceccbc62fd4f83fbd43411e5f4392 --- /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 0000000000000000000000000000000000000000..e81e91fe972f0d9f528cbfe7ea92d40f293d1564 --- /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 0000000000000000000000000000000000000000..be2b8c568a83c5dec6e0ccbdb6842d27dd38de6a --- /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 0000000000000000000000000000000000000000..904b3a68507a3d1e897cc8cb938bfad6001198ea --- /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 0000000000000000000000000000000000000000..9b80fb2e99fd89614c331b8f4189023a5b340be6 --- /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 0000000000000000000000000000000000000000..18f26a1f911177e0828af1b640dd96e8a5ee250f --- /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 0000000000000000000000000000000000000000..75f8b73019555ee8af9ba3a5e8e3ddde3f9d2ae8 --- /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 0000000000000000000000000000000000000000..66f645a4b61e608055aa3e53b1aafda3037c9545 --- /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 c82798cc6a58b079e268969b646db5dffedba3b2..c43af17442b46be1cdd0c041d486d6ac6bdff599 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 0000000000000000000000000000000000000000..46d1c3f00c1e599a6b24d4edd2a42103da9c3c18 --- /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("
    "); + } + + 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 1bb0b67d0e86519a07bbec16bf33faaa750417b3..0526430989f277f87cb8e01437a54f387b961ee1 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,62 +30,9 @@ labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %>
    issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <%- label.title %> <% }); %>'); labelNoneHTMLTemplate = 'None'; } - 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) { - $newLabelCreateButton.enable(); - if (label.message != null) { - var errorText = label.message; - if (_.isObject(label.message)) { - errorText = _.map(label.message, function(value, key) { - return key + " " + value[0]; - }).join('
    '); - } - return $newLabelError.html(errorText).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() { @@ -272,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')) + "']"); @@ -291,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')) { @@ -300,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 a0b65d20c03425c71954e1b6d3e968f485dd740c..e897ebdf6304bcdc337fae6d2a60c4d6f3ff779d 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 65d362e072c48527734a81d2f96e9039e1ac2a72..bad82868ab045dbdcd18e83ee6610336757c917a 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 ca720022539cfe006cee1645e2d75e7502278ee8..5da390118c66250cf123858de8501e88426e6d78 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 0000000000000000000000000000000000000000..ad4b2d6496f7677bee156e0ab1832f2e3b073db0 --- /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 0000000000000000000000000000000000000000..3cfb08d5822ee44eb5089ee7a39c52e1d1246955 --- /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/helpers/application_helper.rb b/app/helpers/application_helper.rb index c3613bc67dd005804aec9d506140ffb7db3b8d51..f3733b0172145ab9e1e252bb8392d0f8a22186c9 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/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index a1a71c2fb3392929b669075ece43dc6174168d51..bf50633af244025eaf404f88f3537bbd46370655 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 33cedaaf2eee64e17ae15780b35309b275cb9f83..15a94ac23c56675e974b7d2b732a32a7a77554f6 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 0000000000000000000000000000000000000000..97eb952eff1dffb00c201ca88010fcf75cfe5fcf --- /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 0000000000000000000000000000000000000000..f8ebf397ee27c316171e8b5256ad73009349e5ac --- /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 0000000000000000000000000000000000000000..b20c23f6b8e5848641dfa9f857e78b73b568f8da --- /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 index 44b002d3fe542c7e9faaba2d7780f9dc64ed1446..edbbd3f3d2a01c90f1ed151aacebd1c1a9f0af4f 100644 --- a/app/views/projects/boards/show.html.haml +++ b/app/views/projects/boards/show.html.haml @@ -1,8 +1,19 @@ - @no_container = true -- @content_class = 'issues-board-content' -- page_title 'Board' +- @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 'projects/issues/head' = render 'shared/issuable/filter', type: :boards -.board-lists +.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/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index c957cd84479f25a43fb5efe1b5f715b4af4c8002..ffe8d4fbdbf839f1791825bf73544a79b9e20a49 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 4e280c371acc253702982ad989cf8860e59b2377..a76b7baf9181ec9f3fc40852aca18acd7e59919c 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 4a9ed41cbf88a751b92552933b42f8ff1a761974..0c136623477163963f3284b3adf1dfce53650c00 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/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e4c5a10ce7e7757aabd75841f3c77a8851a2ffe7 --- /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/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..078e4b00023c2574509948a6806eb617b33c70e3 --- /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 0000000000000000000000000000000000000000..3569d1b98bd12f0b3ab042d8b82b281113044d82 --- /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 0000000000000000000000000000000000000000..c206b794442f913c71a96290381e839cd0730ea8 --- /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 0000000000000000000000000000000000000000..0c37ec8354f3efe9385e703b1225bee8240beb3b --- /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/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js new file mode 100644 index 0000000000000000000000000000000000000000..eca7c5012b2b1178f4fd942e95676555344ea208 --- /dev/null +++ b/vendor/assets/javascripts/Sortable.js @@ -0,0 +1,1285 @@ +/**! + * Sortable + * @author RubaXa + * @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 1b1f4f0bd63e9f38ea635607ab0aa9971e5c650d..39d7d2306f8eca30429df7e7af6c9f2b54151037 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 +});