diff options
153 files changed, 8177 insertions, 179 deletions
diff --git a/CHANGELOG b/CHANGELOG index 837e9e27aba..580abe71bcc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -9,6 +9,7 @@ v 8.11.0 (unreleased) - Fix the title of the toggle dropdown button. !5515 (herminiotorres) - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz) - Update to Ruby 2.3.1. !4948 + - Add Issues Board !5548 - Improve diff performance by eliminating redundant checks for text blobs - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi) - Convert switch icon into icon font (ClemMakesApps) @@ -16,6 +17,7 @@ v 8.11.0 (unreleased) - API: List access requests, request access, approve, and deny access requests to a project or a group. !4833 - Use long options for curl examples in documentation !5703 (winniehell) - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) + - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell) - Ignore URLs starting with // in Markdown links !5677 (winniehell) - Fix CI status icon link underline (ClemMakesApps) @@ -90,6 +92,7 @@ v 8.11.0 (unreleased) - Allow branch names ending with .json for graph and network page !5579 (winniehell) - Add the `sprockets-es6` gem - Improve OAuth2 client documentation (muteor) + - Fix diff comments inverted toggle bug (ClemMakesApps) - Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska) - Profile requests when a header is passed - Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab. @@ -120,6 +123,7 @@ v 8.11.0 (unreleased) - Fix a memory leak caused by Banzai::Filter::SanitizationFilter - Speed up todos queries by limiting the projects set we join with - Ensure file editing in UI does not overwrite commited changes without warning user + - Eliminate unneeded calls to Repository#blob_at when listing commits with no path v 8.10.6 - Upgrade Rails to 4.2.7.1 for security fixes. !5781 @@ -314,6 +314,7 @@ end group :test do gem 'shoulda-matchers', '~> 2.8.0', require: false gem 'email_spec', '~> 1.6.0' + gem 'json-schema', '~> 2.6.2' gem 'webmock', '~> 1.21.0' gem 'test_after_commit', '~> 0.4.2' gem 'sham_rack', '~> 1.3.6' diff --git a/Gemfile.lock b/Gemfile.lock index 2244c20203b..58c84c47575 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -356,6 +356,8 @@ GEM jquery-ui-rails (5.0.5) railties (>= 3.2.16) json (1.8.3) + json-schema (2.6.2) + addressable (~> 2.3.8) jwt (1.5.4) kaminari (0.17.0) actionpack (>= 3.0.0) @@ -873,6 +875,7 @@ DEPENDENCIES jquery-rails (~> 4.1.0) jquery-turbolinks (~> 2.1.0) jquery-ui-rails (~> 5.0.0) + json-schema (~> 2.6.2) jwt kaminari (~> 0.17.0) knapsack (~> 1.11.0) diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 new file mode 100644 index 00000000000..2c65d4427be --- /dev/null +++ b/app/assets/javascripts/boards/boards_bundle.js.es6 @@ -0,0 +1,57 @@ +//= require vue +//= require vue-resource +//= require Sortable +//= require_tree ./models +//= require_tree ./stores +//= require_tree ./services +//= require_tree ./mixins +//= require ./components/board +//= require ./components/new_list_dropdown +//= require ./vue_resource_interceptor + +$(() => { + const $boardApp = document.getElementById('board-app'), + Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + + if (gl.IssueBoardsApp) { + gl.IssueBoardsApp.$destroy(true); + } + + gl.IssueBoardsApp = new Vue({ + el: $boardApp, + components: { + 'board': gl.issueBoards.Board + }, + data: { + state: Store.state, + loading: true, + endpoint: $boardApp.dataset.endpoint, + disabled: $boardApp.dataset.disabled === 'true', + issueLinkBase: $boardApp.dataset.issueLinkBase + }, + init: Store.create.bind(Store), + created () { + gl.boardService = new BoardService(this.endpoint); + }, + ready () { + Store.disabled = this.disabled; + gl.boardService.all() + .then((resp) => { + resp.json().forEach((board) => { + const list = Store.addList(board); + + if (list.type === 'done') { + list.position = Infinity; + } else if (list.type === 'backlog') { + list.position = -1; + } + }); + + Store.addBlankState(); + this.loading = false; + }); + } + }); +}); diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6 new file mode 100644 index 00000000000..e17784e7948 --- /dev/null +++ b/app/assets/javascripts/boards/components/board.js.es6 @@ -0,0 +1,85 @@ +//= require ./board_blank_state +//= require ./board_delete +//= require ./board_list + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.Board = Vue.extend({ + components: { + 'board-list': gl.issueBoards.BoardList, + 'board-delete': gl.issueBoards.BoardDelete, + 'board-blank-state': gl.issueBoards.BoardBlankState + }, + props: { + list: Object, + disabled: Boolean, + issueLinkBase: String + }, + data () { + return { + query: '', + filters: Store.state.filters + }; + }, + watch: { + query () { + this.list.filters = this.getFilterData(); + this.list.getIssues(true); + }, + filters: { + handler () { + this.list.page = 1; + this.list.getIssues(true); + }, + deep: true + } + }, + methods: { + getFilterData () { + const filters = this.filters; + let queryData = { search: this.query }; + + Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; }); + + return queryData; + } + }, + ready () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + disabled: this.disabled, + group: 'boards', + draggable: '.is-draggable', + handle: '.js-board-handle', + onEnd: (e) => { + document.body.classList.remove('is-dragging'); + + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { + const order = this.sortable.toArray(), + $board = this.$parent.$refs.board[e.oldIndex + 1], + list = $board.list; + + $board.$destroy(true); + + this.$nextTick(() => { + Store.state.lists.splice(e.newIndex, 0, list); + Store.moveList(list, order); + }); + } + } + }); + + if (bp.getBreakpointSize() === 'xs') { + options.handle = '.js-board-drag-handle'; + } + + this.sortable = Sortable.create(this.$el.parentNode, options); + }, + beforeDestroy () { + Store.state.lists.$remove(this.list); + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6 new file mode 100644 index 00000000000..63d72d857d9 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_blank_state.js.es6 @@ -0,0 +1,49 @@ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardBlankState = Vue.extend({ + data () { + return { + predefinedLabels: [ + new ListLabel({ title: 'Development', color: '#5CB85C' }), + new ListLabel({ title: 'Testing', color: '#F0AD4E' }), + new ListLabel({ title: 'Production', color: '#FF5F00' }), + new ListLabel({ title: 'Ready', color: '#FF0000' }) + ] + } + }, + methods: { + addDefaultLists () { + this.clearBlankState(); + + this.predefinedLabels.forEach((label, i) => { + Store.addList({ + title: label.title, + position: i, + list_type: 'label', + label: { + title: label.title, + color: label.color + } + }); + }); + + // Save the labels + gl.boardService.generateDefaultLists() + .then((resp) => { + resp.json().forEach((listObj) => { + const list = Store.findList('title', listObj.title); + + list.id = listObj.id; + list.label.id = listObj.label.id; + list.getIssues(); + }); + }); + }, + clearBlankState: Store.removeBlankState.bind(Store) + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6 new file mode 100644 index 00000000000..4a7cfeaeab2 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card.js.es6 @@ -0,0 +1,43 @@ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardCard = Vue.extend({ + props: { + list: Object, + issue: Object, + issueLinkBase: String, + disabled: Boolean, + index: Number + }, + methods: { + filterByLabel (label, e) { + let labelToggleText = label.title; + const labelIndex = Store.state.filters['label_name'].indexOf(label.title); + $(e.target).tooltip('hide'); + + if (labelIndex === -1) { + Store.state.filters['label_name'].push(label.title); + $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`); + } else { + Store.state.filters['label_name'].splice(labelIndex, 1); + labelToggleText = Store.state.filters['label_name'][0]; + $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove(); + } + + const selectedLabels = Store.state.filters['label_name']; + if (selectedLabels.length === 0) { + labelToggleText = 'Label'; + } else if (selectedLabels.length > 1) { + labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`; + } + + $('.labels-filter .dropdown-toggle-text').text(labelToggleText); + + Store.updateFiltersUrl(); + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6 new file mode 100644 index 00000000000..34653cd48ef --- /dev/null +++ b/app/assets/javascripts/boards/components/board_delete.js.es6 @@ -0,0 +1,19 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardDelete = Vue.extend({ + props: { + list: Object + }, + methods: { + deleteBoard () { + $(this.$el).tooltip('hide'); + + if (confirm('Are you sure you want to delete this list?')) { + this.list.destroy(); + } + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6 new file mode 100644 index 00000000000..1503d14c508 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list.js.es6 @@ -0,0 +1,89 @@ +//= require ./board_card + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardList = Vue.extend({ + components: { + 'board-card': gl.issueBoards.BoardCard + }, + props: { + disabled: Boolean, + list: Object, + issues: Array, + loading: Boolean, + issueLinkBase: String + }, + data () { + return { + scrollOffset: 250, + filters: Store.state.filters + }; + }, + watch: { + filters: { + handler () { + this.list.loadingMore = false; + this.$els.list.scrollTop = 0; + }, + deep: true + } + }, + methods: { + listHeight () { + return this.$els.list.getBoundingClientRect().height; + }, + scrollHeight () { + return this.$els.list.scrollHeight; + }, + scrollTop () { + return this.$els.list.scrollTop + this.listHeight(); + }, + loadNextPage () { + const getIssues = this.list.nextPage(); + + if (getIssues) { + this.list.loadingMore = true; + getIssues.then(() => { + this.list.loadingMore = false; + }); + } + }, + }, + ready () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + group: 'issues', + sort: false, + disabled: this.disabled, + onStart: (e) => { + const card = this.$refs.issue[e.oldIndex]; + + Store.moving.issue = card.issue; + Store.moving.list = card.list; + }, + onAdd: (e) => { + gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue); + }, + onRemove: (e) => { + this.$refs.issue[e.oldIndex].$destroy(true); + } + }); + + if (bp.getBreakpointSize() === 'xs') { + options.handle = '.js-card-drag-handle'; + } + + this.sortable = Sortable.create(this.$els.list, options); + + // Scroll event on list to load more + this.$els.list.onscroll = () => { + if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { + this.loadNextPage(); + } + }; + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 new file mode 100644 index 00000000000..1a4d8157970 --- /dev/null +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 @@ -0,0 +1,54 @@ +$(() => { + const Store = gl.issueBoards.BoardsStore; + + $('.js-new-board-list').each(function () { + const $this = $(this); + + new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id')); + + $this.glDropdown({ + data(term, callback) { + $.get($this.attr('data-labels')) + .then((resp) => { + callback(resp); + }); + }, + renderRow (label) { + const active = Store.findList('title', label.title), + $li = $('<li />'), + $a = $('<a />', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }), + $labelColor = $('<span />', { + class: 'dropdown-label-box', + style: `background-color: ${label.color}` + }); + + return $li.append($a.prepend($labelColor)); + }, + search: { + fields: ['title'] + }, + filterable: true, + selectable: true, + clicked (label, $el, e) { + e.preventDefault(); + + if (!Store.findList('title', label.title)) { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color + } + }); + } + } + }); + }); +}); diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 new file mode 100644 index 00000000000..b7afe4897b6 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 @@ -0,0 +1,25 @@ +((w) => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { + let defaultSortOptions = { + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + filter: '.has-tooltip', + scrollSensitivity: 100, + scrollSpeed: 20, + onStart () { + document.body.classList.add('is-dragging'); + }, + onEnd () { + document.body.classList.remove('is-dragging'); + } + } + + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); + return defaultSortOptions; + }; +})(window); diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6 new file mode 100644 index 00000000000..eb082103de9 --- /dev/null +++ b/app/assets/javascripts/boards/models/issue.js.es6 @@ -0,0 +1,44 @@ +class ListIssue { + constructor (obj) { + this.id = obj.iid; + this.title = obj.title; + this.confidential = obj.confidential; + this.labels = []; + + if (obj.assignee) { + this.assignee = new ListUser(obj.assignee); + } + + obj.labels.forEach((label) => { + this.labels.push(new ListLabel(label)); + }); + + this.priority = this.labels.reduce((max, label) => { + return (label.priority < max) ? label.priority : max; + }, Infinity); + } + + addLabel (label) { + if (!this.findLabel(label)) { + this.labels.push(new ListLabel(label)); + } + } + + findLabel (findLabel) { + return this.labels.filter( label => label.title === findLabel.title )[0]; + } + + removeLabel (removeLabel) { + if (removeLabel) { + this.labels = this.labels.filter( label => removeLabel.title !== label.title ); + } + } + + removeLabels (labels) { + labels.forEach(this.removeLabel.bind(this)); + } + + getLists () { + return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) ); + } +} diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6 new file mode 100644 index 00000000000..e81e91fe972 --- /dev/null +++ b/app/assets/javascripts/boards/models/label.js.es6 @@ -0,0 +1,9 @@ +class ListLabel { + constructor (obj) { + this.id = obj.id; + this.title = obj.title; + this.color = obj.color; + this.description = obj.description; + this.priority = (obj.priority !== null) ? obj.priority : Infinity; + } +} diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6 new file mode 100644 index 00000000000..be2b8c568a8 --- /dev/null +++ b/app/assets/javascripts/boards/models/list.js.es6 @@ -0,0 +1,125 @@ +class List { + constructor (obj) { + this.id = obj.id; + this._uid = this.guid(); + this.position = obj.position; + this.title = obj.title; + this.type = obj.list_type; + this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1; + this.filters = gl.issueBoards.BoardsStore.state.filters; + this.page = 1; + this.loading = true; + this.loadingMore = false; + this.issues = []; + + if (obj.label) { + this.label = new ListLabel(obj.label); + } + + if (this.type !== 'blank' && this.id) { + this.getIssues(); + } + } + + guid() { + const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + } + + save () { + return gl.boardService.createList(this.label.id) + .then((resp) => { + const data = resp.json(); + + this.id = data.id; + this.type = data.list_type; + this.position = data.position; + + return this.getIssues(); + }); + } + + destroy () { + gl.issueBoards.BoardsStore.state.lists.$remove(this); + gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); + + gl.boardService.destroyList(this.id); + } + + update () { + gl.boardService.updateList(this.id, this.position); + } + + nextPage () { + if (Math.floor(this.issues.length / 20) === this.page) { + this.page++; + + return this.getIssues(false); + } + } + + canSearch () { + return this.type === 'backlog'; + } + + getIssues (emptyIssues = true) { + const filters = this.filters; + let data = { page: this.page }; + + Object.keys(filters).forEach((key) => { data[key] = filters[key]; }); + + if (this.label) { + data.label_name = data.label_name.filter( label => label !== this.label.title ); + } + + if (emptyIssues) { + this.loading = true; + } + + return gl.boardService.getIssuesForList(this.id, data) + .then((resp) => { + const data = resp.json(); + this.loading = false; + + if (emptyIssues) { + this.issues = []; + } + + this.createIssues(data); + }); + } + + createIssues (data) { + data.forEach((issueObj) => { + this.addIssue(new ListIssue(issueObj)); + }); + } + + addIssue (issue, listFrom) { + this.issues.push(issue); + + if (this.label) { + issue.addLabel(this.label); + } + + if (listFrom) { + gl.boardService.moveIssue(issue.id, listFrom.id, this.id); + } + } + + findIssue (id) { + return this.issues.filter( issue => issue.id === id )[0]; + } + + removeIssue (removeIssue) { + this.issues = this.issues.filter((issue) => { + const matchesRemove = removeIssue.id === issue.id; + + if (matchesRemove) { + issue.removeLabel(this.label); + } + + return !matchesRemove; + }); + } +} diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6 new file mode 100644 index 00000000000..904b3a68507 --- /dev/null +++ b/app/assets/javascripts/boards/models/user.js.es6 @@ -0,0 +1,8 @@ +class ListUser { + constructor (user) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url; + } +} diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6 new file mode 100644 index 00000000000..9b80fb2e99f --- /dev/null +++ b/app/assets/javascripts/boards/services/board_service.js.es6 @@ -0,0 +1,61 @@ +class BoardService { + constructor (root) { + Vue.http.options.root = root; + + this.lists = Vue.resource(`${root}/lists{/id}`, {}, { + generate: { + method: 'POST', + url: `${root}/lists/generate.json` + } + }); + this.issue = Vue.resource(`${root}/issues{/id}`, {}); + this.issues = Vue.resource(`${root}/lists{/id}/issues`, {}); + + Vue.http.interceptors.push((request, next) => { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + next(); + }); + } + + all () { + return this.lists.get(); + } + + generateDefaultLists () { + return this.lists.generate({}); + } + + createList (label_id) { + return this.lists.save({}, { + list: { + label_id + } + }); + } + + updateList (id, position) { + return this.lists.update({ id }, { + list: { + position + } + }); + } + + destroyList (id) { + return this.lists.delete({ id }); + } + + getIssuesForList (id, filter = {}) { + let data = { id }; + Object.keys(filter).forEach((key) => { data[key] = filter[key]; }); + + return this.issues.get(data); + } + + moveIssue (id, from_list_id, to_list_id) { + return this.issue.update({ id }, { + from_list_id, + to_list_id + }); + } +}; diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6 new file mode 100644 index 00000000000..18f26a1f911 --- /dev/null +++ b/app/assets/javascripts/boards/stores/boards_store.js.es6 @@ -0,0 +1,112 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardsStore = { + disabled: false, + state: {}, + moving: { + issue: {}, + list: {} + }, + create () { + this.state.lists = []; + this.state.filters = { + author_id: gl.utils.getParameterValues('author_id')[0], + assignee_id: gl.utils.getParameterValues('assignee_id')[0], + milestone_title: gl.utils.getParameterValues('milestone_title')[0], + label_name: gl.utils.getParameterValues('label_name[]') + }; + }, + addList (listObj) { + const list = new List(listObj); + this.state.lists.push(list); + + return list; + }, + new (listObj) { + const list = this.addList(listObj), + backlogList = this.findList('type', 'backlog', 'backlog'); + + list + .save() + .then(() => { + // Remove any new issues from the backlog + // as they will be visible in the new list + list.issues.forEach(backlogList.removeIssue.bind(backlogList)); + }); + this.removeBlankState(); + }, + updateNewListDropdown (listId) { + $(`.js-board-list-${listId}`).removeClass('is-active'); + }, + shouldAddBlankState () { + // Decide whether to add the blank state + return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]); + }, + addBlankState () { + if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; + + this.addList({ + id: 'blank', + list_type: 'blank', + title: 'Welcome to your Issue Board!', + position: 0 + }); + }, + removeBlankState () { + this.removeList('blank'); + + $.cookie('issue_board_welcome_hidden', 'true', { + expires: 365 * 10 + }); + }, + welcomeIsHidden () { + return $.cookie('issue_board_welcome_hidden') === 'true'; + }, + removeList (id, type = 'blank') { + const list = this.findList('id', id, type); + + if (!list) return; + + this.state.lists = this.state.lists.filter( list => list.id !== id ); + }, + moveList (listFrom, orderLists) { + orderLists.forEach((id, i) => { + const list = this.findList('id', parseInt(id)); + + list.position = i; + }); + listFrom.update(); + }, + moveIssueToList (listFrom, listTo, issue) { + const issueTo = listTo.findIssue(issue.id), + issueLists = issue.getLists(), + listLabels = issueLists.map( listIssue => listIssue.label ); + + // Add to new lists issues if it doesn't already exist + if (!issueTo) { + listTo.addIssue(issue, listFrom); + } + + if (listTo.type === 'done' && listFrom.type !== 'backlog') { + issueLists.forEach((list) => { + list.removeIssue(issue); + }) + issue.removeLabels(listLabels); + } else { + listFrom.removeIssue(issue); + } + }, + findList (key, val, type = 'label') { + return this.state.lists.filter((list) => { + const byType = type ? list['type'] === type : true; + + return list[key] === val && byType; + })[0]; + }, + updateFiltersUrl () { + history.pushState(null, null, `?${$.param(this.state.filters)}`); + } + }; +})(); diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/boards/test_utils/simulate_drag.js new file mode 100755 index 00000000000..75f8b730195 --- /dev/null +++ b/app/assets/javascripts/boards/test_utils/simulate_drag.js @@ -0,0 +1,119 @@ +(function () { + 'use strict'; + + function simulateEvent(el, type, options) { + var event; + if (!el) return; + var ownerDocument = el.ownerDocument; + + options = options || {}; + + if (/^mouse/.test(type)) { + event = ownerDocument.createEvent('MouseEvents'); + event.initMouseEvent(type, true, true, ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + } else { + event = ownerDocument.createEvent('CustomEvent'); + + event.initCustomEvent(type, true, true, ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + + event.dataTransfer = { + data: {}, + + setData: function (type, val) { + this.data[type] = val; + }, + + getData: function (type) { + return this.data[type]; + } + }; + } + + if (el.dispatchEvent) { + el.dispatchEvent(event); + } else if (el.fireEvent) { + el.fireEvent('on' + type, event); + } + + return event; + } + + function getTraget(target) { + var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; + var children = el.children; + + return ( + children[target.index] || + children[target.index === 'first' ? 0 : -1] || + children[target.index === 'last' ? children.length - 1 : -1] + ); + } + + function getRect(el) { + var rect = el.getBoundingClientRect(); + var width = rect.right - rect.left; + var height = rect.bottom - rect.top; + + return { + x: rect.left, + y: rect.top, + cx: rect.left + width / 2, + cy: rect.top + height / 2, + w: width, + h: height, + hw: width / 2, + wh: height / 2 + }; + } + + function simulateDrag(options, callback) { + options.to.el = options.to.el || options.from.el; + + var fromEl = getTraget(options.from); + var toEl = getTraget(options.to); + var scrollable = options.scrollable; + + var fromRect = getRect(fromEl); + var toRect = getRect(toEl); + + var startTime = new Date().getTime(); + var duration = options.duration || 1000; + simulateEvent(fromEl, 'mousedown', {button: 0}); + options.ontap && options.ontap(); + window.SIMULATE_DRAG_ACTIVE = 1; + + var dragInterval = setInterval(function loop() { + var progress = (new Date().getTime() - startTime) / duration; + var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft; + var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop; + var overEl = fromEl.ownerDocument.elementFromPoint(x, y); + + simulateEvent(overEl, 'mousemove', { + clientX: x, + clientY: y + }); + + if (progress >= 1) { + options.ondragend && options.ondragend(); + simulateEvent(toEl, 'mouseup'); + clearInterval(dragInterval); + window.SIMULATE_DRAG_ACTIVE = 0; + } + }, 100); + + return { + target: fromEl, + fromList: fromEl.parentNode, + toList: toEl.parentNode + }; + } + + + // Export + window.simulateEvent = simulateEvent; + window.simulateDrag = simulateDrag; +})(); diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..66f645a4b61 --- /dev/null +++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 @@ -0,0 +1,8 @@ +Vue.http.interceptors.push((request, next) => { + Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; + + setTimeout(() => { + Vue.activeResources--; + }, 500); + next(); +}); diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js index c82798cc6a5..c43af17442b 100644 --- a/app/assets/javascripts/copy_to_clipboard.js +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -34,6 +34,7 @@ $(function() { var clipboard; + clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); clipboard.on('success', genericSuccess); return clipboard.on('error', genericError); diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6 new file mode 100644 index 00000000000..46d1c3f00c1 --- /dev/null +++ b/app/assets/javascripts/create_label.js.es6 @@ -0,0 +1,126 @@ +(function (w) { + class CreateLabelDropdown { + constructor ($el, projectId) { + this.$el = $el; + this.projectId = projectId; + this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown')); + this.$cancelButton = $('.js-cancel-label-btn', this.$el); + this.$newLabelField = $('#new_label_name', this.$el); + this.$newColorField = $('#new_label_color', this.$el); + this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); + this.$newLabelError = $('.js-label-error', this.$el); + this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); + this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); + + this.$newLabelError.hide(); + this.$newLabelCreateButton.disable(); + + this.cleanBinding(); + this.addBinding(); + } + + cleanBinding () { + this.$colorSuggestions.off('click'); + this.$newLabelField.off('keyup change'); + this.$newColorField.off('keyup change'); + this.$dropdownBack.off('click'); + this.$cancelButton.off('click'); + this.$newLabelCreateButton.off('click'); + } + + addBinding () { + const self = this; + + this.$colorSuggestions.on('click', function (e) { + const $this = $(this); + self.addColorValue(e, $this); + }); + + this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this)); + this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this)); + + this.$dropdownBack.on('click', this.resetForm.bind(this)); + + this.$cancelButton.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + self.resetForm(); + self.$dropdownBack.trigger('click'); + }); + + this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); + } + + addColorValue (e, $this) { + e.preventDefault(); + e.stopPropagation(); + + this.$newColorField.val($this.data('color')).trigger('change'); + this.$colorPreview + .css('background-color', $this.data('color')) + .parent() + .addClass('is-active'); + } + + enableLabelCreateButton () { + if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { + this.$newLabelError.hide(); + this.$newLabelCreateButton.enable(); + } else { + this.$newLabelCreateButton.disable(); + } + } + + resetForm () { + this.$newLabelField + .val('') + .trigger('change'); + + this.$newColorField + .val('') + .trigger('change'); + + this.$colorPreview + .css('background-color', '') + .parent() + .removeClass('is-active'); + } + + saveLabel (e) { + e.preventDefault(); + e.stopPropagation(); + + Api.newLabel(this.projectId, { + name: this.$newLabelField.val(), + color: this.$newColorField.val() + }, (label) => { + this.$newLabelCreateButton.enable(); + + if (label.message) { + let errors; + + if (typeof label.message === 'string') { + errors = label.message; + } else { + errors = label.message.map(function (value, key) { + return key + " " + value[0]; + }).join("<br/>"); + } + + this.$newLabelError + .html(errors) + .show(); + } else { + this.$dropdownBack.trigger('click'); + } + }); + } + } + + if (!w.gl) { + w.gl = {}; + } + + gl.CreateLabelDropdown = CreateLabelDropdown; +})(window); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 7160fa71ce5..1163edd8547 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -88,6 +88,8 @@ new ZenMode(); new MergedButtons(); break; + case "projects:merge_requests:conflicts": + window.mcui = new MergeConflictResolver() case 'projects:merge_requests:index': shortcut_handler = new ShortcutsNavigation(); Issuable.init(); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 1bb0b67d0e8..0526430989f 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,7 +4,7 @@ var _this; _this = this; $('.js-label-select').each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo; + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); labelUrl = $dropdown.data('labels'); @@ -13,8 +13,6 @@ if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - newLabelField = $('#new_label_name'); - newColorField = $('#new_label_color'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); defaultLabel = $dropdown.data('default-label'); @@ -24,10 +22,6 @@ $form = $dropdown.closest('form'); $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); $value = $block.find('.value'); - $newLabelError = $('.js-label-error'); - $colorPreview = $('.js-dropdown-label-color-preview'); - $newLabelCreateButton = $('.js-new-label-btn'); - $newLabelError.hide(); $loading = $block.find('.block-loading').fadeOut(); if (issueUpdateURL != null) { issueURLSplit = issueUpdateURL.split('/'); @@ -36,62 +30,9 @@ labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); labelNoneHTMLTemplate = '<span class="no-value">None</span>'; } - if (newLabelField.length) { - $('.suggest-colors-dropdown a').on("click", function(e) { - e.preventDefault(); - e.stopPropagation(); - newColorField.val($(this).data('color')).trigger('change'); - return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active'); - }); - resetForm = function() { - newLabelField.val('').trigger('change'); - newColorField.val('').trigger('change'); - return $colorPreview.css('background-color', '').parent().removeClass('is-active'); - }; - $('.dropdown-menu-back').on('click', function() { - return resetForm(); - }); - $('.js-cancel-label-btn').on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - resetForm(); - return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); - }); - enableLabelCreateButton = function() { - if (newLabelField.val() !== '' && newColorField.val() !== '') { - $newLabelError.hide(); - return $newLabelCreateButton.enable(); - } else { - return $newLabelCreateButton.disable(); - } - }; - saveLabel = function() { - return Api.newLabel(projectId, { - name: newLabelField.val(), - color: newColorField.val() - }, function(label) { - $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('<br/>'); - } - 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/merge_conflict_data_provider.js.es6 b/app/assets/javascripts/merge_conflict_data_provider.js.es6 new file mode 100644 index 00000000000..cd92df8ddc5 --- /dev/null +++ b/app/assets/javascripts/merge_conflict_data_provider.js.es6 @@ -0,0 +1,341 @@ +const HEAD_HEADER_TEXT = 'HEAD//our changes'; +const ORIGIN_HEADER_TEXT = 'origin//their changes'; +const HEAD_BUTTON_TITLE = 'Use ours'; +const ORIGIN_BUTTON_TITLE = 'Use theirs'; + + +class MergeConflictDataProvider { + + getInitialData() { + const diffViewType = $.cookie('diff_view'); + + return { + isLoading : true, + hasError : false, + isParallel : diffViewType === 'parallel', + diffViewType : diffViewType, + isSubmitting : false, + conflictsData : {}, + resolutionData : {} + } + } + + + decorateData(vueInstance, data) { + this.vueInstance = vueInstance; + + if (data.type === 'error') { + vueInstance.hasError = true; + data.errorMessage = data.message; + } + else { + data.shortCommitSha = data.commit_sha.slice(0, 7); + data.commitMessage = data.commit_message; + + this.setParallelLines(data); + this.setInlineLines(data); + this.updateResolutionsData(data); + } + + vueInstance.conflictsData = data; + vueInstance.isSubmitting = false; + + const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict'; + vueInstance.conflictsData.conflictsText = conflictsText; + } + + + updateResolutionsData(data) { + const vi = this.vueInstance; + + data.files.forEach( (file) => { + file.sections.forEach( (section) => { + if (section.conflict) { + vi.$set(`resolutionData['${section.id}']`, false); + } + }); + }); + } + + + setParallelLines(data) { + data.files.forEach( (file) => { + file.filePath = this.getFilePath(file); + file.iconClass = `fa-${file.blob_icon}`; + file.blobPath = file.blob_path; + file.parallelLines = []; + const linesObj = { left: [], right: [] }; + + file.sections.forEach( (section) => { + const { conflict, lines, id } = section; + + if (conflict) { + linesObj.left.push(this.getOriginHeaderLine(id)); + linesObj.right.push(this.getHeadHeaderLine(id)); + } + + lines.forEach( (line) => { + const { type } = line; + + if (conflict) { + if (type === 'old') { + linesObj.left.push(this.getLineForParallelView(line, id, 'conflict')); + } + else if (type === 'new') { + linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true)); + } + } + else { + const lineType = type || 'context'; + + linesObj.left.push (this.getLineForParallelView(line, id, lineType)); + linesObj.right.push(this.getLineForParallelView(line, id, lineType, true)); + } + }); + + this.checkLineLengths(linesObj); + }); + + for (let i = 0, len = linesObj.left.length; i < len; i++) { + file.parallelLines.push([ + linesObj.right[i], + linesObj.left[i] + ]); + } + + }); + } + + + checkLineLengths(linesObj) { + let { left, right } = linesObj; + + if (left.length !== right.length) { + if (left.length > right.length) { + const diff = left.length - right.length; + for (let i = 0; i < diff; i++) { + right.push({ lineType: 'emptyLine', richText: '' }); + } + } + else { + const diff = right.length - left.length; + for (let i = 0; i < diff; i++) { + left.push({ lineType: 'emptyLine', richText: '' }); + } + } + } + } + + + setInlineLines(data) { + data.files.forEach( (file) => { + file.iconClass = `fa-${file.blob_icon}`; + file.blobPath = file.blob_path; + file.filePath = this.getFilePath(file); + file.inlineLines = [] + + file.sections.forEach( (section) => { + let currentLineType = 'new'; + const { conflict, lines, id } = section; + + if (conflict) { + file.inlineLines.push(this.getHeadHeaderLine(id)); + } + + lines.forEach( (line) => { + const { type } = line; + + if ((type === 'new' || type === 'old') && currentLineType !== type) { + currentLineType = type; + file.inlineLines.push({ lineType: 'emptyLine', richText: '' }); + } + + this.decorateLineForInlineView(line, id, conflict); + file.inlineLines.push(line); + }) + + if (conflict) { + file.inlineLines.push(this.getOriginHeaderLine(id)); + } + }); + }); + } + + + handleSelected(sectionId, selection) { + const vi = this.vueInstance; + + vi.resolutionData[sectionId] = selection; + vi.conflictsData.files.forEach( (file) => { + file.inlineLines.forEach( (line) => { + if (line.id === sectionId && (line.hasConflict || line.isHeader)) { + this.markLine(line, selection); + } + }); + + file.parallelLines.forEach( (lines) => { + const left = lines[0]; + const right = lines[1]; + const hasSameId = right.id === sectionId || left.id === sectionId; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (hasSameId && (isLeftMatch || isRightMatch)) { + this.markLine(left, selection); + this.markLine(right, selection); + } + }) + }); + } + + + updateViewType(newType) { + const vi = this.vueInstance; + + if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) { + return; + } + + vi.diffView = newType; + vi.isParallel = newType === 'parallel'; + $.cookie('diff_view', newType); // TODO: Make sure that cookie path added. + $('.content-wrapper .container-fluid').toggleClass('container-limited'); + } + + + markLine(line, selection) { + if (selection === 'head' && line.isHead) { + line.isSelected = true; + line.isUnselected = false; + } + else if (selection === 'origin' && line.isOrigin) { + line.isSelected = true; + line.isUnselected = false; + } + else { + line.isSelected = false; + line.isUnselected = true; + } + } + + + getConflictsCount() { + return Object.keys(this.vueInstance.resolutionData).length; + } + + + getResolvedCount() { + let count = 0; + const data = this.vueInstance.resolutionData; + + for (const id in data) { + const resolution = data[id]; + if (resolution) { + count++; + } + } + + return count; + } + + + isReadyToCommit() { + const { conflictsData, isSubmitting } = this.vueInstance + const allResolved = this.getConflictsCount() === this.getResolvedCount(); + const hasCommitMessage = $.trim(conflictsData.commitMessage).length; + + return !isSubmitting && hasCommitMessage && allResolved; + } + + + getCommitButtonText() { + const initial = 'Commit conflict resolution'; + const inProgress = 'Committing...'; + const vue = this.vueInstance; + + return vue ? vue.isSubmitting ? inProgress : initial : initial; + } + + + decorateLineForInlineView(line, id, conflict) { + const { type } = line; + line.id = id; + line.hasConflict = conflict; + line.isHead = type === 'new'; + line.isOrigin = type === 'old'; + line.hasMatch = type === 'match'; + line.richText = line.rich_text; + line.isSelected = false; + line.isUnselected = false; + } + + getLineForParallelView(line, id, lineType, isHead) { + const { old_line, new_line, rich_text } = line; + const hasConflict = lineType === 'conflict'; + + return { + id, + lineType, + hasConflict, + isHead : hasConflict && isHead, + isOrigin : hasConflict && !isHead, + hasMatch : lineType === 'match', + lineNumber : isHead ? new_line : old_line, + section : isHead ? 'head' : 'origin', + richText : rich_text, + isSelected : false, + isUnselected : false + } + } + + + getHeadHeaderLine(id) { + return { + id : id, + richText : HEAD_HEADER_TEXT, + buttonTitle : HEAD_BUTTON_TITLE, + type : 'new', + section : 'head', + isHeader : true, + isHead : true, + isSelected : false, + isUnselected: false + } + } + + + getOriginHeaderLine(id) { + return { + id : id, + richText : ORIGIN_HEADER_TEXT, + buttonTitle : ORIGIN_BUTTON_TITLE, + type : 'old', + section : 'origin', + isHeader : true, + isOrigin : true, + isSelected : false, + isUnselected: false + } + } + + + handleFailedRequest(vueInstance, data) { + vueInstance.hasError = true; + vueInstance.conflictsData.errorMessage = 'Something went wrong!'; + } + + + getCommitData() { + return { + commit_message: this.vueInstance.conflictsData.commitMessage, + sections: this.vueInstance.resolutionData + } + } + + + getFilePath(file) { + const { old_path, new_path } = file; + return old_path === new_path ? new_path : `${old_path} → ${new_path}`; + } + +} diff --git a/app/assets/javascripts/merge_conflict_resolver.js.es6 b/app/assets/javascripts/merge_conflict_resolver.js.es6 new file mode 100644 index 00000000000..77bffbcb403 --- /dev/null +++ b/app/assets/javascripts/merge_conflict_resolver.js.es6 @@ -0,0 +1,85 @@ +//= require vue + +class MergeConflictResolver { + + constructor() { + this.dataProvider = new MergeConflictDataProvider() + this.initVue() + } + + + initVue() { + const that = this; + this.vue = new Vue({ + el : '#conflicts', + name : 'MergeConflictResolver', + data : this.dataProvider.getInitialData(), + created : this.fetchData(), + computed : this.setComputedProperties(), + methods : { + handleSelected(sectionId, selection) { + that.dataProvider.handleSelected(sectionId, selection); + }, + handleViewTypeChange(newType) { + that.dataProvider.updateViewType(newType); + }, + commit() { + that.commit(); + } + } + }) + } + + + setComputedProperties() { + const dp = this.dataProvider; + + return { + conflictsCount() { return dp.getConflictsCount() }, + resolvedCount() { return dp.getResolvedCount() }, + readyToCommit() { return dp.isReadyToCommit() }, + commitButtonText() { return dp.getCommitButtonText() } + } + } + + + fetchData() { + const dp = this.dataProvider; + + $.get($('#conflicts').data('conflictsPath')) + .done((data) => { + dp.decorateData(this.vue, data); + }) + .error((data) => { + dp.handleFailedRequest(this.vue, data); + }) + .always(() => { + this.vue.isLoading = false; + + this.vue.$nextTick(() => { + $('#conflicts .js-syntax-highlight').syntaxHighlight(); + }); + + if (this.vue.diffViewType === 'parallel') { + $('.content-wrapper .container-fluid').removeClass('container-limited'); + } + }) + } + + + commit() { + this.vue.isSubmitting = true; + + $.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData()) + .done((data) => { + window.location.href = data.redirect_to; + }) + .error(() => { + new Flash('Something went wrong!'); + }) + .always(() => { + this.vue.isSubmitting = false; + }); + } + +} diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 75aa6c7a491..ab9d2367fc7 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -9,6 +9,8 @@ MergeRequestTabs.prototype.buildsLoaded = false; + MergeRequestTabs.prototype.pipelinesLoaded = false; + MergeRequestTabs.prototype.commitsLoaded = false; function MergeRequestTabs(opts) { @@ -50,6 +52,9 @@ } else if (action === 'builds') { this.loadBuilds($target.attr('href')); this.expandView(); + } else if (action === 'pipelines') { + this.loadPipelines($target.attr('href')); + this.expandView(); } else { this.expandView(); } @@ -82,7 +87,7 @@ action = 'notes'; } this.currentAction = action; - new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, ''); + new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, ''); if (action !== 'notes') { new_state += "/" + action; } @@ -183,6 +188,21 @@ }); }; + MergeRequestTabs.prototype.loadPipelines = function(source) { + if (this.pipelinesLoaded) { + return; + } + return this._get({ + url: source + ".json", + success: function(data) { + $('#pipelines').html(data.html); + gl.utils.localTimeAgo($('.js-timeago', '#pipelines')); + this.pipelinesLoaded = true; + return this.scrollToElement("#pipelines"); + }.bind(this) + }); + }; + MergeRequestTabs.prototype.toggleLoading = function(status) { return $('.mr-loading-status .loading').toggle(status); }; diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 362aaa906d0..bd35b6f679d 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -28,7 +28,7 @@ MergeRequestWidget.prototype.addEventListeners = function() { var allowedPages; - allowedPages = ['show', 'commits', 'builds', 'changes']; + allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes']; return $(document).on('page:change.merge_request', (function(_this) { return function() { var page; @@ -53,7 +53,7 @@ return function(data) { var callback, urlSuffix; if (data.state === "merged") { - urlSuffix = deleteSourceBranch ? '?delete_source=true' : ''; + urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : ''; return window.location.href = window.location.pathname + urlSuffix; } else if (data.merge_error) { return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>"); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index a0b65d20c03..e897ebdf630 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -94,7 +94,7 @@ $selectbox.hide(); return $value.css('display', ''); }, - clicked: function(selected) { + clicked: function(selected, $el, e) { var data, isIssueIndex, isMRIndex, page; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -102,7 +102,11 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { return; } - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (page === 'projects:boards:show') { + gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name; + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (selected.name != null) { selectedMilestone = selected.name; } else { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 65d362e072c..bad82868ab0 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -141,7 +141,7 @@ $selectbox.hide(); return $value.css('display', ''); }, - clicked: function(user) { + clicked: function(user, $el, e) { var isIssueIndex, isMRIndex, page, selected; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -149,7 +149,12 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { return; } - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (page === 'projects:boards:show') { + selectedId = user.id; + gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id; + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { selectedId = user.id; return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 5ec5a96a597..d2d60ed7196 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -123,4 +123,9 @@ } } } -}
\ No newline at end of file +} + +@mixin dark-diff-match-line { + color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ca720022539..5da390118c6 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb; $ci-output-bg: #1d1f21; $ci-text-color: #c5c8c6; + +$issue-boards-font-size: 15px; diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 77a73dc379b..16ffbe57a99 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #557; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index 80a509a7c1a..7de920e074b 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #49483e; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index c62bd021aef..b11499c71ee 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #174652; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 524cfaf90c3..657bb5e3cd9 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -1,4 +1,10 @@ /* https://gist.github.com/qguv/7936275 */ + +@mixin matchLine { + color: $black-transparent; + background: rgba(255, 255, 255, 0.4); +} + .code.solarized-light { // Line numbers .line-numbers, .diff-line-num { @@ -21,6 +27,10 @@ // Diff line .line_holder { + &.match .line_content { + @include matchLine; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #ddd8c5; @@ -36,8 +46,7 @@ } .line_content.match { - color: $black-transparent; - background: rgba(255, 255, 255, 0.4); + @include matchLine; } } diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 31a4e3deaac..36a80a916b2 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -1,4 +1,10 @@ /* https://github.com/aahan/pygments-github-style */ + +@mixin matchLine { + color: $black-transparent; + background-color: $match-line; +} + .code.white { // Line numbers .line-numbers, .diff-line-num { @@ -22,6 +28,10 @@ // Diff line .line_holder { + &.match .line_content { + @include matchLine; + } + .diff-line-num { &.old { background-color: $line-number-old; @@ -57,8 +67,7 @@ } &.match { - color: $black-transparent; - background-color: $match-line; + @include matchLine; } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss new file mode 100644 index 00000000000..ad4b2d6496f --- /dev/null +++ b/app/assets/stylesheets/pages/boards.scss @@ -0,0 +1,329 @@ +[v-cloak] { + display: none; +} + +.user-can-drag { + cursor: -webkit-grab; + cursor: grab; +} + +.is-dragging { + * { + cursor: -webkit-grabbing; + cursor: grabbing; + } +} + +.dropdown-menu-issues-board-new { + width: 320px; + + .dropdown-content { + max-height: 150px; + } +} + +.issue-board-dropdown-content { + margin: 0 8px 10px; + padding-bottom: 10px; + border-bottom: 1px solid $dropdown-divider-color; + + > p { + margin: 0; + font-size: 14px; + color: #9c9c9c; + } +} + +.issue-boards-page { + .content-wrapper { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + } + + .sub-nav, + .issues-filters { + -webkit-flex: none; + flex: none; + } + + .page-with-sidebar { + display: -webkit-flex; + display: flex; + min-height: 100vh; + max-height: 100vh; + padding-bottom: 0; + } + + .issue-boards-content { + display: -webkit-flex; + display: flex; + -webkit-flex: 1; + flex: 1; + width: 100%; + + .content { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + width: 100%; + } + } +} + +.boards-app-loading { + width: 100%; + font-size: 34px; +} + +.boards-list { + display: -webkit-flex; + display: flex; + -webkit-flex: 1; + flex: 1; + -webkit-flex-basis: 0; + flex-basis: 0; + min-height: calc(100vh - 152px); + max-height: calc(100vh - 152px); + padding-top: 25px; + padding-right: ($gl-padding / 2); + padding-left: ($gl-padding / 2); + overflow-x: scroll; + + @media (min-width: $screen-sm-min) { + min-height: 475px; + max-height: none; + } +} + +.board { + display: -webkit-flex; + display: flex; + min-width: calc(100vw - 15px); + max-width: calc(100vw - 15px); + margin-bottom: 25px; + padding-right: ($gl-padding / 2); + padding-left: ($gl-padding / 2); + + @media (min-width: $screen-sm-min) { + min-width: 400px; + max-width: 400px; + } +} + +.board-inner { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + width: 100%; + font-size: $issue-boards-font-size; + background: $background-color; + border: 1px solid $border-color; + border-radius: $border-radius-default; +} + +.board-header { + border-top-left-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; + + &.has-border { + border-top: 3px solid; + + .board-title { + padding-top: ($gl-padding - 3px); + } + } +} + +.board-header-loading-spinner { + margin-right: 10px; + color: $gray-darkest; +} + +.board-inner-container { + border-bottom: 1px solid $border-color; + padding: $gl-padding; +} + +.board-title { + position: relative; + margin: 0; + padding: $gl-padding; + font-size: 1em; + border-bottom: 1px solid $border-color; + + .board-mobile-handle { + position: relative; + left: 0; + top: 1px; + margin-top: 0; + margin-right: 5px; + } +} + +.board-search-container { + position: relative; + background-color: #fff; + + .form-control { + padding-right: 30px; + } +} + +.board-search-icon, +.board-search-clear-btn { + position: absolute; + right: $gl-padding + 10px; + top: 50%; + margin-top: -7px; + font-size: 14px; +} + +.board-search-icon { + color: $gl-placeholder-color; +} + +.board-search-clear-btn { + padding: 0; + line-height: 1; + background: transparent; + border: 0; + outline: 0; + + &:hover { + color: $gl-link-color; + } +} + +.board-delete { + margin-right: 10px; + padding: 0; + color: $gray-darkest; + background-color: transparent; + border: 0; + outline: 0; + + &:hover { + color: $gl-link-color; + } +} + +.board-blank-state { + height: 100%; + padding: $gl-padding; + background-color: #fff; +} + +.board-blank-state-list { + list-style: none; + + > li:not(:last-child) { + margin-bottom: 8px; + } + + .label-color { + position: relative; + top: 2px; + display: inline-block; + width: 16px; + height: 16px; + margin-right: 3px; + border-radius: $border-radius-default; + } +} + +.board-list { + -webkit-flex: 1; + flex: 1; + height: 400px; + margin-bottom: 0; + padding: 5px; + overflow-y: scroll; + overflow-x: hidden; +} + +.board-list-loading { + margin-top: 10px; + font-size: 26px; +} + +.is-ghost { + opacity: 0.3; +} + +.is-dragging { + // Important because plugin sets inline CSS + opacity: 1!important; +} + +.card { + position: relative; + width: 100%; + padding: 10px $gl-padding; + background: #fff; + border-radius: $border-radius-default; + box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5); + list-style: none; + + &.user-can-drag { + padding-left: ($gl-padding * 2); + + @media (min-width: $screen-sm-min) { + padding-left: $gl-padding; + } + } + + &:not(:last-child) { + margin-bottom: 5px; + } + + a { + cursor: pointer; + } + + .label { + border: 0; + outline: 0; + } + + .confidential-icon { + margin-right: 5px; + } +} + +.board-mobile-handle { + position: absolute; + left: 10px; + top: 50%; + margin-top: (-15px / 2); + + @media (min-width: $screen-sm-min) { + display: none; + } +} + +.card-title { + margin: 0; + font-size: 1em; + + a { + color: inherit; + } +} + +.card-footer { + margin-top: 5px; + + .label { + margin-right: 4px; + font-size: (14px / $issue-boards-font-size) * 1em; + } +} + +.card-number { + margin-right: 8px; + font-weight: 500; +} diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss new file mode 100644 index 00000000000..1f499897c16 --- /dev/null +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -0,0 +1,238 @@ +$colors: ( + white_header_head_neutral : #e1fad7, + white_line_head_neutral : #effdec, + white_button_head_neutral : #9adb84, + + white_header_head_chosen : #baf0a8, + white_line_head_chosen : #e1fad7, + white_button_head_chosen : #52c22d, + + white_header_origin_neutral : #e0f0ff, + white_line_origin_neutral : #f2f9ff, + white_button_origin_neutral : #87c2fa, + + white_header_origin_chosen : #add8ff, + white_line_origin_chosen : #e0f0ff, + white_button_origin_chosen : #268ced, + + white_header_not_chosen : #f0f0f0, + white_line_not_chosen : #f9f9f9, + + + dark_header_head_neutral : rgba(#3f3, .2), + dark_line_head_neutral : rgba(#3f3, .1), + dark_button_head_neutral : #40874f, + + dark_header_head_chosen : rgba(#3f3, .33), + dark_line_head_chosen : rgba(#3f3, .2), + dark_button_head_chosen : #258537, + + dark_header_origin_neutral : rgba(#2878c9, .4), + dark_line_origin_neutral : rgba(#2878c9, .3), + dark_button_origin_neutral : #2a5c8c, + + dark_header_origin_chosen : rgba(#2878c9, .6), + dark_line_origin_chosen : rgba(#2878c9, .4), + dark_button_origin_chosen : #1d6cbf, + + dark_header_not_chosen : rgba(#fff, .25), + dark_line_not_chosen : rgba(#fff, .1), + + + monokai_header_head_neutral : rgba(#a6e22e, .25), + monokai_line_head_neutral : rgba(#a6e22e, .1), + monokai_button_head_neutral : #376b20, + + monokai_header_head_chosen : rgba(#a6e22e, .4), + monokai_line_head_chosen : rgba(#a6e22e, .25), + monokai_button_head_chosen : #39800d, + + monokai_header_origin_neutral : rgba(#60d9f1, .35), + monokai_line_origin_neutral : rgba(#60d9f1, .15), + monokai_button_origin_neutral : #38848c, + + monokai_header_origin_chosen : rgba(#60d9f1, .5), + monokai_line_origin_chosen : rgba(#60d9f1, .35), + monokai_button_origin_chosen : #3ea4b2, + + monokai_header_not_chosen : rgba(#76715d, .24), + monokai_line_not_chosen : rgba(#76715d, .1), + + + solarized_light_header_head_neutral : rgba(#859900, .37), + solarized_light_line_head_neutral : rgba(#859900, .2), + solarized_light_button_head_neutral : #afb262, + + solarized_light_header_head_chosen : rgba(#859900, .5), + solarized_light_line_head_chosen : rgba(#859900, .37), + solarized_light_button_head_chosen : #94993d, + + solarized_light_header_origin_neutral : rgba(#2878c9, .37), + solarized_light_line_origin_neutral : rgba(#2878c9, .15), + solarized_light_button_origin_neutral : #60a1bf, + + solarized_light_header_origin_chosen : rgba(#2878c9, .6), + solarized_light_line_origin_chosen : rgba(#2878c9, .37), + solarized_light_button_origin_chosen : #2482b2, + + solarized_light_header_not_chosen : rgba(#839496, .37), + solarized_light_line_not_chosen : rgba(#839496, .2), + + + solarized_dark_header_head_neutral : rgba(#859900, .35), + solarized_dark_line_head_neutral : rgba(#859900, .15), + solarized_dark_button_head_neutral : #376b20, + + solarized_dark_header_head_chosen : rgba(#859900, .5), + solarized_dark_line_head_chosen : rgba(#859900, .35), + solarized_dark_button_head_chosen : #39800d, + + solarized_dark_header_origin_neutral : rgba(#2878c9, .35), + solarized_dark_line_origin_neutral : rgba(#2878c9, .15), + solarized_dark_button_origin_neutral : #086799, + + solarized_dark_header_origin_chosen : rgba(#2878c9, .6), + solarized_dark_line_origin_chosen : rgba(#2878c9, .35), + solarized_dark_button_origin_chosen : #0082cc, + + solarized_dark_header_not_chosen : rgba(#839496, .25), + solarized_dark_line_not_chosen : rgba(#839496, .15) +); + + +@mixin color-scheme($color) { + .header.line_content, .diff-line-num { + &.origin { + background-color: map-get($colors, #{$color}_header_origin_neutral); + border-color: map-get($colors, #{$color}_header_origin_neutral); + + button { + background-color: map-get($colors, #{$color}_button_origin_neutral); + border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15); + } + + &.selected { + background-color: map-get($colors, #{$color}_header_origin_chosen); + border-color: map-get($colors, #{$color}_header_origin_chosen); + + button { + background-color: map-get($colors, #{$color}_button_origin_chosen); + border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15); + } + } + + &.unselected { + background-color: map-get($colors, #{$color}_header_not_chosen); + border-color: map-get($colors, #{$color}_header_not_chosen); + + button { + background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15); + border-color: map-get($colors, #{$color}_button_origin_neutral); + } + } + } + &.head { + background-color: map-get($colors, #{$color}_header_head_neutral); + border-color: map-get($colors, #{$color}_header_head_neutral); + + button { + background-color: map-get($colors, #{$color}_button_head_neutral); + border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15); + } + + &.selected { + background-color: map-get($colors, #{$color}_header_head_chosen); + border-color: map-get($colors, #{$color}_header_head_chosen); + + button { + background-color: map-get($colors, #{$color}_button_head_chosen); + border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15); + } + } + + &.unselected { + background-color: map-get($colors, #{$color}_header_not_chosen); + border-color: map-get($colors, #{$color}_header_not_chosen); + + button { + background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15); + border-color: map-get($colors, #{$color}_button_head_neutral); + } + } + } + } + + .line_content { + &.origin { + background-color: map-get($colors, #{$color}_line_origin_neutral); + + &.selected { + background-color: map-get($colors, #{$color}_line_origin_chosen); + } + + &.unselected { + background-color: map-get($colors, #{$color}_line_not_chosen); + } + } + &.head { + background-color: map-get($colors, #{$color}_line_head_neutral); + + &.selected { + background-color: map-get($colors, #{$color}_line_head_chosen); + } + + &.unselected { + background-color: map-get($colors, #{$color}_line_not_chosen); + } + } + } +} + + +#conflicts { + + .white { + @include color-scheme('white') + } + + .dark { + @include color-scheme('dark') + } + + .monokai { + @include color-scheme('monokai') + } + + .solarized-light { + @include color-scheme('solarized_light') + } + + .solarized-dark { + @include color-scheme('solarized_dark') + } + + .diff-wrap-lines .line_content { + white-space: normal; + min-height: 19px; + } + + .line_content.header { + position: relative; + + button { + border-radius: 2px; + font-size: 10px; + position: absolute; + right: 10px; + padding: 0; + outline: none; + color: #fff; + width: 75px; // static width to make 2 buttons have same width + height: 19px; + } + } + + .btn-success .fa-spinner { + color: #fff; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 21919fe4d73..50ac4d8449b 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -229,3 +229,15 @@ box-shadow: none; } } + +.pipelines.tab-pane { + + .content-list.pipelines { + overflow: scroll; + } + + .stage { + max-width: 60px; + width: 60px; + } +} diff --git a/app/controllers/projects/board_lists_controller.rb b/app/controllers/projects/board_lists_controller.rb new file mode 100644 index 00000000000..3cfb08d5822 --- /dev/null +++ b/app/controllers/projects/board_lists_controller.rb @@ -0,0 +1,65 @@ +class Projects::BoardListsController < Projects::ApplicationController + respond_to :json + + before_action :authorize_admin_list! + + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + def create + list = Boards::Lists::CreateService.new(project, current_user, list_params).execute + + if list.valid? + render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } }) + else + render json: list.errors, status: :unprocessable_entity + end + end + + def update + service = Boards::Lists::MoveService.new(project, current_user, move_params) + + if service.execute + head :ok + else + head :unprocessable_entity + end + end + + def destroy + service = Boards::Lists::DestroyService.new(project, current_user, params) + + if service.execute + head :ok + else + head :unprocessable_entity + end + end + + def generate + service = Boards::Lists::GenerateService.new(project, current_user) + + if service.execute + render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } }) + else + head :unprocessable_entity + end + end + + private + + def authorize_admin_list! + return render_403 unless can?(current_user, :admin_list, project) + end + + def list_params + params.require(:list).permit(:label_id) + end + + def move_params + params.require(:list).permit(:position).merge(id: params[:id]) + end + + def record_not_found(exception) + render json: { error: exception.message }, status: :not_found + end +end diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb new file mode 100644 index 00000000000..dad38fff6b9 --- /dev/null +++ b/app/controllers/projects/boards/application_controller.rb @@ -0,0 +1,15 @@ +module Projects + module Boards + class ApplicationController < Projects::ApplicationController + respond_to :json + + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + private + + def record_not_found(exception) + render json: { error: exception.message }, status: :not_found + end + end + end +end diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb new file mode 100644 index 00000000000..2d894b3dd4a --- /dev/null +++ b/app/controllers/projects/boards/issues_controller.rb @@ -0,0 +1,56 @@ +module Projects + module Boards + class IssuesController < Boards::ApplicationController + before_action :authorize_read_issue!, only: [:index] + before_action :authorize_update_issue!, only: [:update] + + def index + issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute + issues = issues.page(params[:page]) + + render json: issues.as_json( + only: [:iid, :title, :confidential], + include: { + assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + labels: { only: [:id, :title, :description, :color, :priority] } + }) + end + + def update + service = ::Boards::Issues::MoveService.new(project, current_user, move_params) + + if service.execute(issue) + head :ok + else + head :unprocessable_entity + end + end + + private + + def issue + @issue ||= + IssuesFinder.new(current_user, project_id: project.id, state: 'all') + .execute + .where(iid: params[:id]) + .first! + end + + def authorize_read_issue! + return render_403 unless can?(current_user, :read_issue, project) + end + + def authorize_update_issue! + return render_403 unless can?(current_user, :update_issue, issue) + end + + def filter_params + params.merge(id: params[:list_id]) + end + + def move_params + params.permit(:id, :from_list_id, :to_list_id) + end + end + end +end diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb new file mode 100644 index 00000000000..b995f586737 --- /dev/null +++ b/app/controllers/projects/boards/lists_controller.rb @@ -0,0 +1,81 @@ +module Projects + module Boards + class ListsController < Boards::ApplicationController + before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate] + before_action :authorize_read_list!, only: [:index] + + def index + render json: serialize_as_json(project.board.lists) + end + + def create + list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute + + if list.valid? + render json: serialize_as_json(list) + else + render json: list.errors, status: :unprocessable_entity + end + end + + def update + list = project.board.lists.movable.find(params[:id]) + service = ::Boards::Lists::MoveService.new(project, current_user, move_params) + + if service.execute(list) + head :ok + else + head :unprocessable_entity + end + end + + def destroy + list = project.board.lists.destroyable.find(params[:id]) + service = ::Boards::Lists::DestroyService.new(project, current_user, params) + + if service.execute(list) + head :ok + else + head :unprocessable_entity + end + end + + def generate + service = ::Boards::Lists::GenerateService.new(project, current_user) + + if service.execute + render json: serialize_as_json(project.board.lists.movable) + else + head :unprocessable_entity + end + end + + private + + def authorize_admin_list! + return render_403 unless can?(current_user, :admin_list, project) + end + + def authorize_read_list! + return render_403 unless can?(current_user, :read_list, project) + end + + def list_params + params.require(:list).permit(:label_id) + end + + def move_params + params.require(:list).permit(:position) + end + + def serialize_as_json(resource) + resource.as_json( + only: [:id, :list_type, :position], + methods: [:title], + include: { + label: { only: [:id, :title, :description, :color, :priority] } + }) + end + end + end +end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb new file mode 100644 index 00000000000..33206717089 --- /dev/null +++ b/app/controllers/projects/boards_controller.rb @@ -0,0 +1,15 @@ +class Projects::BoardsController < Projects::ApplicationController + respond_to :html + + before_action :authorize_read_board!, only: [:show] + + def show + ::Boards::CreateService.new(project, current_user).execute + end + + private + + def authorize_read_board! + return access_denied! unless can?(current_user, :read_board, project) + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c92c4407035..e1462cf0941 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :module_enabled before_action :merge_request, only: [ - :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip + :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check, + :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts ] - before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] - before_action :define_show_vars, only: [:show, :diffs, :commits, :builds] + before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines] + before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines] before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check] before_action :define_commit_vars, only: [:diffs] before_action :define_diff_comment_vars, only: [:diffs] - before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds] + before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines] # Allow read any merge_request before_action :authorize_read_merge_request! @@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController # Allow modify merge_request before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort] + before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts] + def index terms = params['issue_search'] @merge_requests = merge_requests_collection @@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + def conflicts + respond_to do |format| + format.html { define_discussion_vars } + + format.json do + if @merge_request.conflicts_can_be_resolved_in_ui? + render json: @merge_request.conflicts + elsif @merge_request.can_be_merged? + render json: { + message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.', + type: 'error' + } + else + render json: { + message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.', + type: 'error' + } + end + end + end + end + + def resolve_conflicts + return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + + if @merge_request.can_be_merged? + render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' } + return + end + + begin + MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request) + + flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' + + render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) } + rescue Gitlab::Conflict::File::MissingResolution => e + render status: :bad_request, json: { message: e.message } + end + end + def builds respond_to do |format| format.html do @@ -141,6 +184,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + def pipelines + @pipelines = @merge_request.all_pipelines + + respond_to do |format| + format.html do + define_discussion_vars + + render 'show' + end + format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } } + end + end + def new build_merge_request @noteable = @merge_request @@ -338,6 +394,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_merge_request, @merge_request) end + def authorize_can_resolve_conflicts! + return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user) + end + def module_enabled return render_404 unless @project.merge_requests_enabled end @@ -409,7 +469,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController noteable_id: @merge_request.id } - @use_legacy_diff_notes = !@merge_request.support_new_diff_notes? + @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? @grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions Banzai::NoteRenderer.render( diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c3613bc67dd..f3733b01721 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -320,4 +320,8 @@ module ApplicationHelper capture(&block) end end + + def page_class + "issue-boards-page" if current_controller?(:boards) + end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 7a02d0b10d9..33dcee49aee 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -98,28 +98,31 @@ module CommitsHelper end def link_to_browse_code(project, commit) - if current_controller?(:projects, :commits) - if @repo.blob_at(commit.id, @path) - return link_to( - "Browse File", - namespace_project_blob_path(project.namespace, project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) - elsif @path.present? - return link_to( - "Browse Directory", - namespace_project_tree_path(project.namespace, project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) - end + if @path.blank? + return link_to( + "Browse Files", + namespace_project_tree_path(project.namespace, project, commit), + class: "btn btn-default" + ) + end + + return unless current_controller?(:projects, :commits) + + if @repo.blob_at(commit.id, @path) + return link_to( + "Browse File", + namespace_project_blob_path(project.namespace, project, + tree_join(commit.id, @path)), + class: "btn btn-default" + ) + elsif @path.present? + return link_to( + "Browse Directory", + namespace_project_tree_path(project.namespace, project, + tree_join(commit.id, @path)), + class: "btn btn-default" + ) end - link_to( - "Browse Files", - namespace_project_tree_path(project.namespace, project, commit), - class: "btn btn-default" - ) end def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 3ff8be5e284..6c1cc6ef072 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -24,6 +24,7 @@ module NavHelper current_path?('merge_requests#diffs') || current_path?('merge_requests#commits') || current_path?('merge_requests#builds') || + current_path?('merge_requests#conflicts') || current_path?('issues#show') if cookies[:collapsed_gutter] == 'true' "page-gutter right-sidebar-collapsed" diff --git a/app/models/ability.rb b/app/models/ability.rb index ce5d7ce0dad..07f703f205d 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -90,6 +90,8 @@ class Ability if project && project.public? rules = [ :read_project, + :read_board, + :read_list, :read_wiki, :read_label, :read_milestone, @@ -228,6 +230,8 @@ class Ability :read_project, :read_wiki, :read_issue, + :read_board, + :read_list, :read_label, :read_milestone, :read_project_snippet, @@ -249,6 +253,7 @@ class Ability :update_issue, :admin_issue, :admin_label, + :admin_list, :read_commit_status, :read_build, :read_container_image, diff --git a/app/models/board.rb b/app/models/board.rb new file mode 100644 index 00000000000..3240c4bede3 --- /dev/null +++ b/app/models/board.rb @@ -0,0 +1,7 @@ +class Board < ActiveRecord::Base + belongs_to :project + + has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all + + validates :project, presence: true +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 1313739f322..ed091b34925 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -109,7 +109,11 @@ class DiffNote < Note private def supported? +<<<<<<< HEAD for_commit? || self.noteable.support_new_diff_notes? +======= + !self.for_merge_request? || self.noteable.has_complete_diff_refs? +>>>>>>> master end def noteable_diff_refs diff --git a/app/models/label.rb b/app/models/label.rb index 35e678001dc..a23140b7d64 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -13,6 +13,8 @@ class Label < ActiveRecord::Base default_value_for :color, DEFAULT_COLOR belongs_to :project + + has_many :lists, dependent: :destroy has_many :label_links, dependent: :destroy has_many :issues, through: :label_links, source: :target, source_type: 'Issue' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' diff --git a/app/models/list.rb b/app/models/list.rb new file mode 100644 index 00000000000..eb87decdbc8 --- /dev/null +++ b/app/models/list.rb @@ -0,0 +1,34 @@ +class List < ActiveRecord::Base + belongs_to :board + belongs_to :label + + enum list_type: { backlog: 0, label: 1, done: 2 } + + validates :board, :list_type, presence: true + validates :label, :position, presence: true, if: :label? + validates :label_id, uniqueness: { scope: :board_id }, if: :label? + validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label? + + before_destroy :can_be_destroyed + + scope :destroyable, -> { where(list_type: list_types[:label]) } + scope :movable, -> { where(list_type: list_types[:label]) } + + def destroyable? + label? + end + + def movable? + label? + end + + def title + label? ? label.name : list_type.humanize + end + + private + + def can_be_destroyed + destroyable? + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 31badb54188..39e4d182bb7 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -700,10 +700,21 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end + def commits_sha + commits.map(&:sha) + end + def pipeline @pipeline ||= source_project.pipeline(diff_head_sha, source_branch) if diff_head_sha && source_project end + def all_pipelines + @all_pipelines ||= + if diff_head_sha && source_project + source_project.pipelines.order(id: :desc).where(sha: commits_sha, ref: source_branch) + end + end + def merge_commit @merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha end @@ -716,12 +727,12 @@ class MergeRequest < ActiveRecord::Base merge_commit end - def support_new_diff_notes? + def has_complete_diff_refs? diff_sha_refs && diff_sha_refs.complete? end def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) - return unless support_new_diff_notes? + return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs active_diff_notes = self.notes.diff_notes.select do |note| @@ -749,4 +760,26 @@ class MergeRequest < ActiveRecord::Base def keep_around_commit project.repository.keep_around(self.merge_commit_sha) end + + def conflicts + @conflicts ||= Gitlab::Conflict::FileCollection.new(self) + end + + def conflicts_can_be_resolved_by?(user) + access = ::Gitlab::UserAccess.new(user, project: source_project) + access.can_push_to_branch?(source_branch) + end + + def conflicts_can_be_resolved_in_ui? + return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) + + return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged? + return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs? + + begin + @conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines) + rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing + @conflicts_can_be_resolved_in_ui = false + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index eefdae35615..043da030468 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -62,6 +62,8 @@ class Project < ActiveRecord::Base belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id' belongs_to :namespace + has_one :board, dependent: :destroy + has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id' # Project services diff --git a/app/models/repository.rb b/app/models/repository.rb index e56bac509a4..2494c266cd2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -391,6 +391,8 @@ class Repository expire_exists_cache expire_root_ref_cache expire_emptiness_caches + + repository_event(:create_repository) end # Runs code just before a repository is deleted. @@ -407,6 +409,8 @@ class Repository expire_root_ref_cache expire_emptiness_caches expire_exists_cache + + repository_event(:remove_repository) end # Runs code just before the HEAD of a repository is changed. @@ -414,6 +418,8 @@ class Repository # Cached divergent commit counts are based on repository head expire_branch_cache expire_root_ref_cache + + repository_event(:change_default_branch) end # Runs code before pushing (= creating or removing) a tag. @@ -421,12 +427,16 @@ class Repository expire_cache expire_tags_cache expire_tag_count_cache + + repository_event(:push_tag) end # Runs code before removing a tag. def before_remove_tag expire_tags_cache expire_tag_count_cache + + repository_event(:remove_tag) end def before_import @@ -443,6 +453,8 @@ class Repository # Runs code after a new commit has been pushed. def after_push_commit(branch_name, revision) expire_cache(branch_name, revision) + + repository_event(:push_commit, branch: branch_name) end # Runs code after a new branch has been created. @@ -450,11 +462,15 @@ class Repository expire_branches_cache expire_has_visible_content_cache expire_branch_count_cache + + repository_event(:push_branch) end # Runs code before removing an existing branch. def before_remove_branch expire_branches_cache + + repository_event(:remove_branch) end # Runs code after an existing branch has been removed. @@ -869,6 +885,14 @@ class Repository end end + def resolve_conflicts(user, branch, params) + commit_with_hooks(user, branch) do + committer = user_to_committer(user) + + Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + end + end + def check_revert_content(commit, base_branch) source_sha = find_branch(base_branch).target.sha args = [commit.id, source_sha] @@ -1059,4 +1083,8 @@ class Repository def keep_around_ref_name(sha) "refs/keep-around/#{sha}" end + + def repository_event(event, tags = {}) + Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) + end end diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb new file mode 100644 index 00000000000..b2069ca825a --- /dev/null +++ b/app/services/boards/base_service.rb @@ -0,0 +1,5 @@ +module Boards + class BaseService < ::BaseService + delegate :board, to: :project + end +end diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb new file mode 100644 index 00000000000..072a0749285 --- /dev/null +++ b/app/services/boards/create_service.rb @@ -0,0 +1,16 @@ +module Boards + class CreateService < Boards::BaseService + def execute + create_board! unless project.board.present? + project.board + end + + private + + def create_board! + project.create_board + project.board.lists.create(list_type: :backlog) + project.board.lists.create(list_type: :done) + end + end +end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb new file mode 100644 index 00000000000..435a8c6e681 --- /dev/null +++ b/app/services/boards/issues/list_service.rb @@ -0,0 +1,63 @@ +module Boards + module Issues + class ListService < Boards::BaseService + def execute + issues = IssuesFinder.new(current_user, filter_params).execute + issues = without_board_labels(issues) unless list.movable? + issues = with_list_label(issues) if list.movable? + issues + end + + private + + def list + @list ||= board.lists.find(params[:id]) + end + + def filter_params + set_default_scope + set_default_sort + set_project + set_state + + params + end + + def set_default_scope + params[:scope] = 'all' + end + + def set_default_sort + params[:sort] = 'priority' + end + + def set_project + params[:project_id] = project.id + end + + def set_state + params[:state] = list.done? ? 'closed' : 'opened' + end + + def board_label_ids + @board_label_ids ||= board.lists.movable.pluck(:label_id) + end + + def without_board_labels(issues) + return issues unless board_label_ids.any? + + issues.where.not( + LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id") + .where(label_id: board_label_ids).limit(1).arel.exists + ) + end + + def with_list_label(issues) + issues.where( + LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id") + .where("label_links.label_id = ?", list.label_id).limit(1).arel.exists + ) + end + end + end +end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb new file mode 100644 index 00000000000..84dc3f70e76 --- /dev/null +++ b/app/services/boards/issues/move_service.rb @@ -0,0 +1,59 @@ +module Boards + module Issues + class MoveService < Boards::BaseService + def execute(issue) + return false unless can?(current_user, :update_issue, issue) + return false unless valid_move? + + update_service.execute(issue) + end + + private + + def valid_move? + moving_from_list.present? && moving_to_list.present? && + moving_from_list != moving_to_list + end + + def moving_from_list + @moving_from_list ||= board.lists.find_by(id: params[:from_list_id]) + end + + def moving_to_list + @moving_to_list ||= board.lists.find_by(id: params[:to_list_id]) + end + + def update_service + ::Issues::UpdateService.new(project, current_user, issue_params) + end + + def issue_params + { + add_label_ids: add_label_ids, + remove_label_ids: remove_label_ids, + state_event: issue_state + } + end + + def issue_state + return 'reopen' if moving_from_list.done? + return 'close' if moving_to_list.done? + end + + def add_label_ids + [moving_to_list.label_id].compact + end + + def remove_label_ids + label_ids = + if moving_to_list.movable? + moving_from_list.label_id + else + board.lists.movable.pluck(:label_id) + end + + Array(label_ids).compact + end + end + end +end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb new file mode 100644 index 00000000000..5cb408b9d20 --- /dev/null +++ b/app/services/boards/lists/create_service.rb @@ -0,0 +1,22 @@ +module Boards + module Lists + class CreateService < Boards::BaseService + def execute + List.transaction do + create_list_at(next_position) + end + end + + private + + def next_position + max_position = board.lists.movable.maximum(:position) + max_position.nil? ? 0 : max_position.succ + end + + def create_list_at(position) + board.lists.create(params.merge(list_type: :label, position: position)) + end + end + end +end diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb new file mode 100644 index 00000000000..25da3bfb56d --- /dev/null +++ b/app/services/boards/lists/destroy_service.rb @@ -0,0 +1,25 @@ +module Boards + module Lists + class DestroyService < Boards::BaseService + def execute(list) + return false unless list.destroyable? + + list.with_lock do + decrement_higher_lists(list) + remove_list(list) + end + end + + private + + def decrement_higher_lists(list) + board.lists.movable.where('position > ?', list.position) + .update_all('position = position - 1') + end + + def remove_list(list) + list.destroy + end + end + end +end diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb new file mode 100644 index 00000000000..1c48b9786e4 --- /dev/null +++ b/app/services/boards/lists/generate_service.rb @@ -0,0 +1,36 @@ +module Boards + module Lists + class GenerateService < Boards::BaseService + def execute + return false unless board.lists.movable.empty? + + List.transaction do + label_params.each { |params| create_list(params) } + end + + true + end + + private + + def create_list(params) + label = find_or_create_label(params) + Lists::CreateService.new(project, current_user, label_id: label.id).execute + end + + def find_or_create_label(params) + project.labels.create_with(color: params[:color]) + .find_or_create_by(name: params[:name]) + end + + def label_params + [ + { name: 'Development', color: '#5CB85C' }, + { name: 'Testing', color: '#F0AD4E' }, + { name: 'Production', color: '#FF5F00' }, + { name: 'Ready', color: '#FF0000' } + ] + end + end + end +end diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb new file mode 100644 index 00000000000..020ff69f4a7 --- /dev/null +++ b/app/services/boards/lists/move_service.rb @@ -0,0 +1,51 @@ +module Boards + module Lists + class MoveService < Boards::BaseService + def execute(list) + @old_position = list.position + @new_position = params[:position] + + return false unless list.movable? + return false unless valid_move? + + list.with_lock do + reorder_intermediate_lists + update_list_position(list) + end + end + + private + + attr_reader :old_position, :new_position + + def valid_move? + new_position.present? && new_position != old_position && + new_position >= 0 && new_position < board.lists.movable.size + end + + def reorder_intermediate_lists + if old_position < new_position + decrement_intermediate_lists + else + increment_intermediate_lists + end + end + + def decrement_intermediate_lists + board.lists.movable.where('position > ?', old_position) + .where('position <= ?', new_position) + .update_all('position = position - 1') + end + + def increment_intermediate_lists + board.lists.movable.where('position >= ?', new_position) + .where('position < ?', old_position) + .update_all('position = position + 1') + end + + def update_list_position(list) + list.update_attribute(:position, new_position) + end + end + end +end diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb new file mode 100644 index 00000000000..adc71b0c2bc --- /dev/null +++ b/app/services/merge_requests/resolve_service.rb @@ -0,0 +1,31 @@ +module MergeRequests + class ResolveService < MergeRequests::BaseService + attr_accessor :conflicts, :rugged, :merge_index + + def execute(merge_request) + @conflicts = merge_request.conflicts + @rugged = project.repository.rugged + @merge_index = conflicts.merge_index + + conflicts.files.each do |file| + write_resolved_file_to_index(file, params[:sections]) + end + + commit_params = { + message: params[:commit_message] || conflicts.default_commit_message, + parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid), + tree: merge_index.write_tree(rugged) + } + + project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params) + end + + def write_resolved_file_to_index(file, resolutions) + new_file = file.resolve_lines(resolutions).map(&:text).join("\n") + our_path = file.our_path + + merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) + merge_index.conflict_remove(our_path) + end + end +end diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index a1a71c2fb33..bf50633af24 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -23,7 +23,6 @@ = render "layouts/broadcast" = render "layouts/flash" = yield :flash_message - %div{ class: (container_class unless @no_container) } + %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content - .clearfix - = yield + = yield diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 33cedaaf2ee..15a94ac23c5 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en"} +%html{ lang: "en", class: "#{page_class}" } = render "layouts/head" %body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}} = Gon::Base.render_data diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml new file mode 100644 index 00000000000..97eb952eff1 --- /dev/null +++ b/app/views/projects/boards/components/_blank_state.html.haml @@ -0,0 +1,15 @@ +%board-blank-state{ "inline-template" => true, + "v-if" => "list.id == 'blank'" } + .board-blank-state + %p + Add the following default lists to your Issue Board with one click: + %ul.board-blank-state-list + %li{ "v-for" => "label in predefinedLabels" } + %span.label-color{ ":style" => "{ backgroundColor: label.color } " } + {{ label.title }} + %p + Starting out with the default set of lists will get you right on the way to making the most of your board. + %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" } + Add default lists + %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" } + Nevermind, I'll use my own diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml new file mode 100644 index 00000000000..f8ebf397ee2 --- /dev/null +++ b/app/views/projects/boards/components/_board.html.haml @@ -0,0 +1,44 @@ +%board{ "inline-template" => true, + "v-cloak" => true, + "v-for" => "list in state.lists | orderBy 'position'", + "v-ref:board" => true, + ":list" => "list", + ":disabled" => "disabled", + ":issue-link-base" => "issueLinkBase", + "track-by" => "_uid" } + .board{ ":class" => "{ 'is-draggable': !list.preset }", + ":data-id" => "list.id" } + .board-inner + %header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" } + %h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" } + = icon("align-justify", class: "board-mobile-handle js-board-drag-handle", "v-if" => "(!disabled && !list.preset)") + {{ list.title }} + %span.pull-right{ "v-if" => "list.type !== 'blank'" } + {{ list.issues.length }} + - if can?(current_user, :admin_list, @project) + %board-delete{ "inline-template" => true, + ":list" => "list", + "v-if" => "!list.preset && list.id" } + %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + = icon("trash") + = icon("spinner spin", class: "board-header-loading-spinner pull-right", "v-show" => "list.loadingMore") + .board-inner-container.board-search-container{ "v-if" => "list.canSearch()" } + %input.form-control{ type: "text", placeholder: "Search issues", "v-model" => "query", "debounce" => "250" } + = icon("search", class: "board-search-icon", "v-show" => "!query") + %button.board-search-clear-btn{ type: "button", role: "button", "aria-label" => "Clear search", "@click" => "query = ''", "v-show" => "query" } + = icon("times", class: "board-search-clear") + %board-list{ "inline-template" => true, + "v-if" => "list.type !== 'blank'", + ":list" => "list", + ":issues" => "list.issues", + ":loading" => "list.loading", + ":disabled" => "disabled", + ":issue-link-base" => "issueLinkBase" } + .board-list-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + %ul.board-list{ "v-el:list" => true, + "v-show" => "!loading", + ":data-board" => "list.id" } + = render "projects/boards/components/card" + - if can?(current_user, :admin_list, @project) + = render "projects/boards/components/blank_state" diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml new file mode 100644 index 00000000000..b20c23f6b8e --- /dev/null +++ b/app/views/projects/boards/components/_card.html.haml @@ -0,0 +1,34 @@ +%board-card{ "inline-template" => true, + "v-for" => "issue in issues | orderBy 'priority'", + "v-ref:issue" => true, + ":index" => "$index", + ":list" => "list", + ":issue" => "issue", + ":issue-link-base" => "issueLinkBase", + ":disabled" => "disabled", + "track-by" => "id" } + %li.card{ ":class" => "{ 'user-can-drag': !disabled }", + ":index" => "index" } + = icon("align-justify", class: "board-mobile-handle js-card-drag-handle", "v-if" => "!disabled") + %h4.card-title + = icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential") + %a{ ":href" => "issueLinkBase + '/' + issue.id", + ":title" => "issue.title" } + {{ issue.title }} + .card-footer + %span.card-number + = precede '#' do + {{ issue.id }} + %button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels", + type: "button", + "v-if" => "(!list.label || label.id !== list.label.id)", + "@click" => "filterByLabel(label, $event)", + ":style" => "{ backgroundColor: label.color, color: label.textColor }", + ":title" => "label.description", + data: { container: 'body' } } + {{ label.title }} + %a.has-tooltip{ ":href" => "'/u/' + issue.assignee.username", + ":title" => "'Assigned to ' + issue.assignee.name", + "v-if" => "issue.assignee", + data: { container: 'body' } } + %img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 } diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml new file mode 100644 index 00000000000..edbbd3f3d2a --- /dev/null +++ b/app/views/projects/boards/show.html.haml @@ -0,0 +1,19 @@ +- @no_container = true +- @content_class = "issue-boards-content" +- page_title "Boards" + +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('boards/boards_bundle.js') + = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test? + += render "projects/issues/head" + += render 'shared/issuable/filter', type: :boards + +.boards-list#board-app{ "v-cloak" => true, + "data-endpoint" => "#{namespace_project_board_path(@project.namespace, @project)}", + "data-disabled" => "#{!can?(current_user, :admin_list, @project)}", + "data-issue-link-base" => "#{namespace_project_issues_path(@project.namespace, @project)}" } + .boards-app-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + = render "projects/boards/components/board" diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index 78709a92aed..be387201f8d 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -2,19 +2,21 @@ %tr.commit %td.commit-link = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do - = ci_status_with_icon(status) - - + - if defined?(status_icon_only) && status_icon_only + = ci_icon_for_status(status) + - else + = ci_status_with_icon(status) %td .branch-commit = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do %span ##{pipeline.id} - if pipeline.ref - .icon-container - = pipeline.tag? ? icon('tag') : icon('code-fork') - = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name" - .icon-container - = custom_icon("icon_commit") + - unless defined?(hide_branch) && hide_branch + .icon-container + = pipeline.tag? ? icon('tag') : icon('code-fork') + = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name" + .icon-container + = custom_icon("icon_commit") = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace" - if pipeline.latest? %span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest @@ -53,7 +55,7 @@ - if pipeline.finished_at %p.finished-at = icon("calendar") - #{time_ago_with_tooltip(pipeline.finished_at, short_format: true, skip_js: true)} + #{time_ago_with_tooltip(pipeline.finished_at, short_format: false, skip_js: true)} %td.pipeline-actions .controls.hidden-xs.pull-right diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml new file mode 100644 index 00000000000..29f4ef8f49e --- /dev/null +++ b/app/views/projects/commit/_pipelines_list.haml @@ -0,0 +1,17 @@ +%ul.content-list.pipelines + - if pipelines.blank? + %li + .nothing-here-block No pipelines to show + - else + .table-holder + %table.table.builds + %tbody + %th Status + %th Commit + - pipelines.stages.each do |stage| + %th.stage + %span.has-tooltip{ title: "#{stage.titleize}" } + = stage.titleize + %th + %th + = render pipelines, commit_sha: true, stage: true, allow_retry: true, stages: pipelines.stages, status_icon_only: true, hide_branch: true diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 60b45115b73..b6cb559afcb 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -6,6 +6,11 @@ %span Issues + = nav_link(controller: :boards) do + = link_to namespace_project_board_path(@project.namespace, @project), title: 'Board' do + %span + Board + - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests) = nav_link(controller: :merge_requests) do = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index b6db1ff714d..f8025fc1dbe 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -47,20 +47,24 @@ - if @commits_count.nonzero? %ul.merge-request-tabs.nav-links.no-top.no-bottom %li.notes-tab - = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do + = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do Discussion %span.badge= @merge_request.mr_and_commit_notes.user.count %li.commits-tab - = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do + = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do Commits %span.badge= @commits_count - if @pipeline + %li.pipelines-tab + = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do + Pipelines + %span.badge= @merge_request.all_pipelines.size %li.builds-tab - = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do + = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#builds', action: 'builds', toggle: 'tab' } do Builds %span.badge= @statuses.size %li.diffs-tab - = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do + = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do Changes %span.badge= @merge_request.diff_size %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true } @@ -88,6 +92,8 @@ - # This tab is always loaded via AJAX #builds.builds.tab-pane - # This tab is always loaded via AJAX + #pipelines.pipelines.tab-pane + - # This tab is always loaded via AJAX #diffs.diffs.tab-pane - # This tab is always loaded via AJAX diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml new file mode 100644 index 00000000000..a524936f73c --- /dev/null +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -0,0 +1,29 @@ +- class_bindings = "{ | + 'head': line.isHead, | + 'origin': line.isOrigin, | + 'match': line.hasMatch, | + 'selected': line.isSelected, | + 'unselected': line.isUnselected }" + +- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" += render "projects/merge_requests/show/mr_title" + +.merge-request-details.issuable-details + = render "projects/merge_requests/show/mr_box" + += render 'shared/issuable/sidebar', issuable: @merge_request + +#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json), + resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } } + .loading{"v-if" => "isLoading"} + %i.fa.fa-spinner.fa-spin + + .nothing-here-block{"v-if" => "hasError"} + {{conflictsData.errorMessage}} + + = render partial: "projects/merge_requests/conflicts/commit_stats" + + .files-wrapper{"v-if" => "!isLoading && !hasError"} + = render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings } + = render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings } + = render partial: "projects/merge_requests/conflicts/submit_form" diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml new file mode 100644 index 00000000000..457c467fba9 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml @@ -0,0 +1,20 @@ +.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"} + .inline-parallel-buttons + .btn-group + %a.btn{ | + ":class" => "{'active': !isParallel}", | + "@click" => "handleViewTypeChange('inline')"} + Inline + %a.btn{ | + ":class" => "{'active': isParallel}", | + "@click" => "handleViewTypeChange('parallel')"} + Side-by-side + + .js-toggle-container + .commit-stat-summary + Showing + %strong.cred {{conflictsCount}} {{conflictsData.conflictsText}} + between + %strong {{conflictsData.source_branch}} + and + %strong {{conflictsData.target_branch}} diff --git a/app/views/projects/merge_requests/conflicts/_inline_view.html.haml b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml new file mode 100644 index 00000000000..19c7da4b5e3 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml @@ -0,0 +1,28 @@ +.files{"v-show" => "!isParallel"} + .diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"} + .file-title + %i.fa.fa-fw{":class" => "file.iconClass"} + %strong {{file.filePath}} + .file-actions + %a.btn.view-file.btn-file-option{":href" => "file.blobPath"} + View file @{{conflictsData.shortCommitSha}} + + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight + %table + %tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"} + %template{"v-if" => "!line.isHeader"} + %td.diff-line-num.new_line{":class" => class_bindings} + %a {{line.new_line}} + %td.diff-line-num.old_line{":class" => class_bindings} + %a {{line.old_line}} + %td.line_content{":class" => class_bindings} + {{{line.richText}}} + + %template{"v-if" => "line.isHeader"} + %td.diff-line-num.header{":class" => class_bindings} + %td.diff-line-num.header{":class" => class_bindings} + %td.line_content.header{":class" => class_bindings} + %strong {{{line.richText}}} + %button.btn{"@click" => "handleSelected(line.id, line.section)"} + {{line.buttonTitle}} diff --git a/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml new file mode 100644 index 00000000000..2e6f67c2eaf --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml @@ -0,0 +1,27 @@ +.files{"v-show" => "isParallel"} + .diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"} + .file-title + %i.fa.fa-fw{":class" => "file.iconClass"} + %strong {{file.filePath}} + .file-actions + %a.btn.view-file.btn-file-option{":href" => "file.blobPath"} + View file @{{conflictsData.shortCommitSha}} + + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight + %table + %tr.line_holder.parallel{"v-for" => "section in file.parallelLines"} + %template{"v-for" => "line in section"} + + %template{"v-if" => "line.isHeader"} + %td.diff-line-num.header{":class" => class_bindings} + %td.line_content.header{":class" => class_bindings} + %strong {{line.richText}} + %button.btn{"@click" => "handleSelected(line.id, line.section)"} + {{line.buttonTitle}} + + %template{"v-if" => "!line.isHeader"} + %td.diff-line-num.old_line{":class" => class_bindings} + {{line.lineNumber}} + %td.line_content.parallel{":class" => class_bindings} + {{{line.richText}}} diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml new file mode 100644 index 00000000000..78bd4133ea2 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -0,0 +1,15 @@ +.content-block.oneline-block.files-changed + %strong.resolved-count {{resolvedCount}} + of + %strong.total-count {{conflictsCount}} + conflicts have been resolved + + .commit-message-container.form-group + .max-width-marker + %textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"} + {{{conflictsData.commitMessage}}} + + %button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"} + %span {{commitButtonText}} + + = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel" diff --git a/app/views/projects/merge_requests/show/_builds.html.haml b/app/views/projects/merge_requests/show/_builds.html.haml index 81de60f116c..808ef7fed27 100644 --- a/app/views/projects/merge_requests/show/_builds.html.haml +++ b/app/views/projects/merge_requests/show/_builds.html.haml @@ -1,2 +1 @@ = render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true - diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml new file mode 100644 index 00000000000..afe3f3430c6 --- /dev/null +++ b/app/views/projects/merge_requests/show/_pipelines.html.haml @@ -0,0 +1 @@ += render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml index 19b5d0ff066..7794d6d7df2 100644 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ b/app/views/projects/merge_requests/widget/_merged.html.haml @@ -6,7 +6,7 @@ - if @merge_request.merge_event by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + - if !@merge_request.source_branch_exists? || params[:deleted_source_branch] %p The changes were merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index dc18f715f25..6f5ee5f16c5 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -1,6 +1,12 @@ .mr-state-widget = render 'projects/merge_requests/widget/heading' .mr-widget-body + -# After conflicts are resolved, the user is redirected back to the MR page. + -# There is a short window before background workers run and GitLab processes + -# the new push and commits, during which it will think the conflicts still exist. + -# We send this param to get the widget to treat the MR as having no more conflicts. + - resolved_conflicts = params[:resolved_conflicts] + - if @project.archived? = render 'projects/merge_requests/widget/open/archived' - elsif @merge_request.commits.blank? @@ -9,7 +15,7 @@ = render 'projects/merge_requests/widget/open/missing_branch' - elsif @merge_request.unchecked? = render 'projects/merge_requests/widget/open/check' - - elsif @merge_request.cannot_be_merged? + - elsif @merge_request.cannot_be_merged? && !resolved_conflicts = render 'projects/merge_requests/widget/open/conflicts' - elsif @merge_request.work_in_progress? = render 'projects/merge_requests/widget/open/wip' @@ -19,7 +25,7 @@ = render 'projects/merge_requests/widget/open/not_allowed' - elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed? = render 'projects/merge_requests/widget/open/build_failed' - - elsif @merge_request.can_be_merged? + - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' - if mr_closes_issues.present? diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml index d9efe81701f..ea618263a4a 100644 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ b/app/views/projects/merge_requests/widget/_show.html.haml @@ -23,7 +23,8 @@ preparing: "{{status}} build", normal: "Build {{status}}" }, - builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" + builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", + pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" }; if (typeof merge_request_widget !== 'undefined') { diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml index f000cc38a65..af3096f04d9 100644 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml @@ -3,7 +3,18 @@ This merge request contains merge conflicts %p - Please resolve these conflicts or + Please + - if @merge_request.conflicts_can_be_resolved_by?(current_user) + - if @merge_request.conflicts_can_be_resolved_in_ui? + = link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + - else + %span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"} + resolve these conflicts locally + - else + resolve these conflicts + + or + - if @merge_request.can_be_merged_via_command_line_by?(current_user) #{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}. - else diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index c957cd84479..ffe8d4fbdbf 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -27,7 +27,17 @@ = render "shared/issuable/label_dropdown" .pull-right - = render 'shared/sort_dropdown' + - if controller.controller_name != 'boards' + = render 'shared/sort_dropdown' + - if can?(current_user, :admin_list, @project) + .dropdown + %button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, project_id: @project.try(:id) } } + Create new list + .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable + = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" } + - if can?(current_user, :admin_label, @project) + = render partial: "shared/issuable/label_page_create" + = dropdown_loading - if controller.controller_name == 'issues' .issues_bulk_update.hide diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index 4e280c371ac..a76b7baf918 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -2,8 +2,13 @@ - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels') +- show_boards_content = local_assigns.fetch(:show_boards_content, false) .dropdown-page-one = dropdown_title(title) + - if show_boards_content + .issue-board-dropdown-content + %p + Each label that exists in your issue tracker can have its own dedicated list. Select a label below to add a list to your Board and it will automatically be populated with issues that have that label. To create a list for a label that doesn't exist yet, simply create the label below. = dropdown_filter(filter_placeholder) = dropdown_content - if @project && show_footer @@ -12,7 +17,7 @@ - if can?(current_user, :admin_label, @project) %li %a.dropdown-toggle-page{href: "#"} - Create new + Create new label %li = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do - if show_create && @project && can?(current_user, :admin_label, @project) diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index d69d6037053..61ed1c38ac4 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -5,6 +5,10 @@ class RepositoryForkWorker sidekiq_options queue: :gitlab_shell def perform(project_id, forked_from_repository_storage_path, source_path, target_path) + Gitlab::Metrics.add_event(:fork_repository, + source_path: source_path, + target_path: target_path) + project = Project.find_by_id(project_id) unless project.present? diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 7d819fe78f8..e6701078f71 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -10,6 +10,10 @@ class RepositoryImportWorker @project = Project.find(project_id) @current_user = @project.creator + Gitlab::Metrics.add_event(:import_repository, + import_url: @project.import_url, + path: @project.path_with_namespace) + result = Projects::ImportService.new(project, current_user).execute if result[:status] == :error diff --git a/config/application.rb b/config/application.rb index 60a4e1f134e..6b80f8ddafa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -86,6 +86,8 @@ module Gitlab config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "profile/profile_bundle.js" config.assets.precompile << "diff_notes/diff_notes_bundle.js" + config.assets.precompile << "boards/boards_bundle.js" + config.assets.precompile << "boards/test_utils/simulate_drag.js" config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/*.js" config.assets.precompile << "u2f.js" diff --git a/config/routes.rb b/config/routes.rb index d5393455b89..624e07b0947 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -727,7 +727,9 @@ Rails.application.routes.draw do member do get :commits get :diffs + get :conflicts get :builds + get :pipelines get :merge_check post :merge post :cancel_merge_when_build_succeeds @@ -736,6 +738,7 @@ Rails.application.routes.draw do post :toggle_award_emoji post :remove_wip get :diff_for_path + post :resolve_conflicts end collection do @@ -865,6 +868,20 @@ Rails.application.routes.draw do end end + resource :board, only: [:show] do + scope module: :boards do + resources :issues, only: [:update] + + resources :lists, only: [:index, :create, :update, :destroy] do + collection do + post :generate + end + + resources :issues, only: [:index] + end + end + end + resources :todos, only: [:create] resources :uploads, only: [:create] do diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb index 6441a036e75..0d493fa1c3c 100644 --- a/db/fixtures/development/14_builds.rb +++ b/db/fixtures/development/14_builds.rb @@ -26,24 +26,44 @@ class Gitlab::Seeder::Builds begin BUILDS.each { |opts| build_create!(pipeline, opts) } commit_status_create!(pipeline, name: 'jenkins', status: :success) - print '.' rescue ActiveRecord::RecordInvalid print 'F' + ensure + pipeline.build_updated end end end def pipelines - commits = @project.repository.commits('master', limit: 5) - commits_sha = commits.map { |commit| commit.raw.id } - commits_sha.map do |sha| - @project.ensure_pipeline(sha, 'master') - end + master_pipelines + merge_request_pipelines + end + + def master_pipelines + create_pipelines_for(@project, 'master') rescue [] end + def merge_request_pipelines + @project.merge_requests.last(5).map do |merge_request| + create_pipelines(merge_request.source_project, merge_request.source_branch, merge_request.commits.last(5)) + end.flatten + rescue + [] + end + + def create_pipelines_for(project, ref) + commits = project.repository.commits(ref, limit: 5) + create_pipelines(project, ref, commits) + end + + def create_pipelines(project, ref, commits) + commits.map do |commit| + project.pipelines.create(sha: commit.id, ref: ref) + end + end + def build_create!(pipeline, opts = {}) attributes = build_attributes_for(pipeline, opts) diff --git a/db/migrate/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb new file mode 100644 index 00000000000..56afbd4e030 --- /dev/null +++ b/db/migrate/20160727191041_create_boards.rb @@ -0,0 +1,13 @@ +class CreateBoards < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :boards do |t| + t.references :project, index: true, foreign_key: true, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160727193336_create_lists.rb b/db/migrate/20160727193336_create_lists.rb new file mode 100644 index 00000000000..61d501215f2 --- /dev/null +++ b/db/migrate/20160727193336_create_lists.rb @@ -0,0 +1,16 @@ +class CreateLists < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :lists do |t| + t.references :board, index: true, foreign_key: true, null: false + t.references :label, index: true, foreign_key: true + t.integer :list_type, null: false, default: 1 + t.integer :position + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb new file mode 100644 index 00000000000..baf2e70b127 --- /dev/null +++ b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb @@ -0,0 +1,15 @@ +class AddUniqueIndexToListsLabelId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :lists, [:board_id, :label_id], unique: true + end + + def down + remove_index :lists, column: [:board_id, :label_id] if index_exists?(:lists, [:board_id, :label_id], unique: true) + end +end diff --git a/db/schema.rb b/db/schema.rb index 0bc1c967af3..68651a45fa6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -117,6 +117,14 @@ ActiveRecord::Schema.define(version: 20160817154936) do add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree + create_table "boards", force: :cascade do |t| + t.integer "project_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree + create_table "broadcast_messages", force: :cascade do |t| t.text "message", null: false t.datetime "starts_at" @@ -533,6 +541,19 @@ ActiveRecord::Schema.define(version: 20160817154936) do add_index "lfs_objects_projects", ["project_id"], name: "index_lfs_objects_projects_on_project_id", using: :btree + create_table "lists", force: :cascade do |t| + t.integer "board_id", null: false + t.integer "label_id" + t.integer "list_type", default: 1, null: false + t.integer "position" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree + add_index "lists", ["board_id"], name: "index_lists_on_board_id", using: :btree + add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree + create_table "members", force: :cascade do |t| t.integer "access_level", null: false t.integer "source_id", null: false @@ -1120,6 +1141,9 @@ ActiveRecord::Schema.define(version: 20160817154936) do add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree + add_foreign_key "boards", "projects" + add_foreign_key "lists", "boards" + add_foreign_key "lists", "labels" add_foreign_key "personal_access_tokens", "users" add_foreign_key "protected_branch_merge_access_levels", "protected_branches" add_foreign_key "protected_branch_push_access_levels", "protected_branches" diff --git a/doc/monitoring/performance/influxdb_schema.md b/doc/monitoring/performance/influxdb_schema.md index 41861860b6d..eff0e29f58d 100644 --- a/doc/monitoring/performance/influxdb_schema.md +++ b/doc/monitoring/performance/influxdb_schema.md @@ -9,6 +9,7 @@ The following measurements are currently stored in InfluxDB: - `PROCESS_object_counts` - `PROCESS_transactions` - `PROCESS_views` +- `events` Here, `PROCESS` is replaced with either `rails` or `sidekiq` depending on the process type. In all series, any form of duration is stored in milliseconds. @@ -78,6 +79,14 @@ following value fields are available: The `action` tag contains the action name of the transaction that rendered the view. +## events + +This measurement is used to store generic events such as the number of Git +pushes, Emails sent, etc. Each point in this measurement has a single value +field called `count`. The value of this field is simply set to `1`. Each point +also has at least one tag: `event`. This tag's value is set to the event name. +Depending on the event type additional tags may be available as well. + --- Read more on: diff --git a/doc/user/project/merge_requests/img/conflict_section.png b/doc/user/project/merge_requests/img/conflict_section.png Binary files differnew file mode 100644 index 00000000000..842e50b14b2 --- /dev/null +++ b/doc/user/project/merge_requests/img/conflict_section.png diff --git a/doc/user/project/merge_requests/img/merge_request_widget.png b/doc/user/project/merge_requests/img/merge_request_widget.png Binary files differnew file mode 100644 index 00000000000..ffb96b17b07 --- /dev/null +++ b/doc/user/project/merge_requests/img/merge_request_widget.png diff --git a/doc/user/project/merge_requests/resolve_conflicts.md b/doc/user/project/merge_requests/resolve_conflicts.md new file mode 100644 index 00000000000..44b76ffc8e6 --- /dev/null +++ b/doc/user/project/merge_requests/resolve_conflicts.md @@ -0,0 +1,41 @@ +# Merge conflict resolution + +> [Introduced][ce-5479] in GitLab 8.11. + +When a merge request has conflicts, GitLab may provide the option to resolve +those conflicts in the GitLab UI. (See +[conflicts available for resolution](#conflicts-available-for-resolution) for +more information on when this is available.) If this is an option, you will see +a **resolve these conflicts** link in the merge request widget: + +![Merge request widget](img/merge_request_widget.png) + +Clicking this will show a list of files with conflicts, with conflict sections +highlighted: + +![Conflict section](img/conflict_section.png) + +Once all conflicts have been marked as using 'ours' or 'theirs', the conflict +can be resolved. This will perform a merge of the target branch of the merge +request into the source branch, resolving the conflicts using the options +chosen. If the source branch is `feature` and the target branch is `master`, +this is similar to performing `git checkout feature; git merge master` locally. + +## Conflicts available for resolution + +GitLab allows resolving conflicts in a file where all of the below are true: + +- The file is text, not binary +- The file does not already contain conflict markers +- The file, with conflict markers added, is not over 200 KB in size +- The file exists under the same path in both branches + +If any file with conflicts in that merge request does not meet all of these +criteria, the conflicts for that merge request cannot be resolved in the UI. + +Additionally, GitLab does not detect conflicts in renames away from a path. For +example, this will not create a conflict: on branch `a`, doing `git mv file1 +file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be +present in the branch after the merge request is merged. + +[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479 diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 260ac81f5fa..9f3b582a263 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -20,8 +20,13 @@ module Ci build = Ci::RegisterBuildService.new.execute(current_runner) if build + Gitlab::Metrics.add_event(:build_found, + project: build.project.path_with_namespace) + present build, with: Entities::BuildDetails else + Gitlab::Metrics.add_event(:build_not_found) + not_found! end end @@ -42,6 +47,9 @@ module Ci build.update_attributes(trace: params[:trace]) if params[:trace] + Gitlab::Metrics.add_event(:update_build, + project: build.project.path_with_namespace) + case params[:state].to_s when 'success' build.success diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb new file mode 100644 index 00000000000..0a1fd27ced5 --- /dev/null +++ b/lib/gitlab/conflict/file.rb @@ -0,0 +1,186 @@ +module Gitlab + module Conflict + class File + include Gitlab::Routing.url_helpers + include IconsHelper + + class MissingResolution < StandardError + end + + CONTEXT_LINES = 3 + + attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository + + def initialize(merge_file_result, conflict, merge_request:) + @merge_file_result = merge_file_result + @their_path = conflict[:theirs][:path] + @our_path = conflict[:ours][:path] + @our_mode = conflict[:ours][:mode] + @merge_request = merge_request + @repository = merge_request.project.repository + @match_line_headers = {} + end + + # Array of Gitlab::Diff::Line objects + def lines + @lines ||= Gitlab::Conflict::Parser.new.parse(merge_file_result[:data], + our_path: our_path, + their_path: their_path, + parent_file: self) + end + + def resolve_lines(resolution) + section_id = nil + + lines.map do |line| + unless line.type + section_id = nil + next line + end + + section_id ||= line_code(line) + + case resolution[section_id] + when 'head' + next unless line.type == 'new' + when 'origin' + next unless line.type == 'old' + else + raise MissingResolution, "Missing resolution for section ID: #{section_id}" + end + + line + end.compact + end + + def highlight_lines! + their_file = lines.reject { |line| line.type == 'new' }.map(&:text).join("\n") + our_file = lines.reject { |line| line.type == 'old' }.map(&:text).join("\n") + + their_highlight = Gitlab::Highlight.highlight(their_path, their_file, repository: repository).lines + our_highlight = Gitlab::Highlight.highlight(our_path, our_file, repository: repository).lines + + lines.each do |line| + if line.type == 'old' + line.rich_text = their_highlight[line.old_line - 1].try(:html_safe) + else + line.rich_text = our_highlight[line.new_line - 1].try(:html_safe) + end + end + end + + def sections + return @sections if @sections + + chunked_lines = lines.chunk { |line| line.type.nil? }.to_a + match_line = nil + + sections_count = chunked_lines.size + + @sections = chunked_lines.flat_map.with_index do |(no_conflict, lines), i| + section = nil + + # We need to reduce context sections to CONTEXT_LINES. Conflict sections are + # always shown in full. + if no_conflict + conflict_before = i > 0 + conflict_after = (sections_count - i) > 1 + + if conflict_before && conflict_after + # Create a gap in a long context section. + if lines.length > CONTEXT_LINES * 2 + head_lines = lines.first(CONTEXT_LINES) + tail_lines = lines.last(CONTEXT_LINES) + + # Ensure any existing match line has text for all lines up to the last + # line of its context. + update_match_line_text(match_line, head_lines.last) + + # Insert a new match line after the created gap. + match_line = create_match_line(tail_lines.first) + + section = [ + { conflict: false, lines: head_lines }, + { conflict: false, lines: tail_lines.unshift(match_line) } + ] + end + elsif conflict_after + tail_lines = lines.last(CONTEXT_LINES) + + # Create a gap and insert a match line at the start. + if lines.length > tail_lines.length + match_line = create_match_line(tail_lines.first) + + tail_lines.unshift(match_line) + end + + lines = tail_lines + elsif conflict_before + # We're at the end of the file (no conflicts after), so just remove extra + # trailing lines. + lines = lines.first(CONTEXT_LINES) + end + end + + # We want to update the match line's text every time unless we've already + # created a gap and its corresponding match line. + update_match_line_text(match_line, lines.last) unless section + + section ||= { conflict: !no_conflict, lines: lines } + section[:id] = line_code(lines.first) unless no_conflict + section + end + end + + def line_code(line) + Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos) + end + + def create_match_line(line) + Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos) + end + + # Any line beginning with a letter, an underscore, or a dollar can be used in a + # match line header. Only context sections can contain match lines, as match lines + # have to exist in both versions of the file. + def find_match_line_header(index) + return @match_line_headers[index] if @match_line_headers.key?(index) + + @match_line_headers[index] = begin + if index >= 0 + line = lines[index] + + if line.type.nil? && line.text.match(/\A[A-Za-z$_]/) + " #{line.text}" + else + find_match_line_header(index - 1) + end + end + end + end + + # Set the match line's text for the current line. A match line takes its start + # position and context header (where present) from itself, and its end position from + # the line passed in. + def update_match_line_text(match_line, line) + return unless match_line + + header = find_match_line_header(match_line.index - 1) + + match_line.text = "@@ -#{match_line.old_pos},#{line.old_pos} +#{match_line.new_pos},#{line.new_pos} @@#{header}" + end + + def as_json(opts = nil) + { + old_path: their_path, + new_path: our_path, + blob_icon: file_type_icon_class('file', our_mode, our_path), + blob_path: namespace_project_blob_path(merge_request.project.namespace, + merge_request.project, + ::File.join(merge_request.diff_refs.head_sha, our_path)), + sections: sections + } + end + end + end +end diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb new file mode 100644 index 00000000000..bbd0427a2c8 --- /dev/null +++ b/lib/gitlab/conflict/file_collection.rb @@ -0,0 +1,57 @@ +module Gitlab + module Conflict + class FileCollection + class ConflictSideMissing < StandardError + end + + attr_reader :merge_request, :our_commit, :their_commit + + def initialize(merge_request) + @merge_request = merge_request + @our_commit = merge_request.source_branch_head.raw.raw_commit + @their_commit = merge_request.target_branch_head.raw.raw_commit + end + + def repository + merge_request.project.repository + end + + def merge_index + @merge_index ||= repository.rugged.merge_commits(our_commit, their_commit) + end + + def files + @files ||= merge_index.conflicts.map do |conflict| + raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours] + + Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]), + conflict, + merge_request: merge_request) + end + end + + def as_json(opts = nil) + { + target_branch: merge_request.target_branch, + source_branch: merge_request.source_branch, + commit_sha: merge_request.diff_head_sha, + commit_message: default_commit_message, + files: files + } + end + + def default_commit_message + conflict_filenames = merge_index.conflicts.map do |conflict| + "# #{conflict[:ours][:path]}" + end + + <<EOM.chomp +Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branch}' + +# Conflicts: +#{conflict_filenames.join("\n")} +EOM + end + end + end +end diff --git a/lib/gitlab/conflict/parser.rb b/lib/gitlab/conflict/parser.rb new file mode 100644 index 00000000000..6eccded7872 --- /dev/null +++ b/lib/gitlab/conflict/parser.rb @@ -0,0 +1,62 @@ +module Gitlab + module Conflict + class Parser + class ParserError < StandardError + end + + class UnexpectedDelimiter < ParserError + end + + class MissingEndDelimiter < ParserError + end + + class UnmergeableFile < ParserError + end + + def parse(text, our_path:, their_path:, parent_file: nil) + raise UnmergeableFile if text.blank? # Typically a binary file + raise UnmergeableFile if text.length > 102400 + + line_obj_index = 0 + line_old = 1 + line_new = 1 + type = nil + lines = [] + conflict_start = "<<<<<<< #{our_path}" + conflict_middle = '=======' + conflict_end = ">>>>>>> #{their_path}" + + text.each_line.map do |line| + full_line = line.delete("\n") + + if full_line == conflict_start + raise UnexpectedDelimiter unless type.nil? + + type = 'new' + elsif full_line == conflict_middle + raise UnexpectedDelimiter unless type == 'new' + + type = 'old' + elsif full_line == conflict_end + raise UnexpectedDelimiter unless type == 'old' + + type = nil + elsif line[0] == '\\' + type = 'nonewline' + lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file) + else + lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file) + line_old += 1 if type != 'new' + line_new += 1 if type != 'old' + + line_obj_index += 1 + end + end + + raise MissingEndDelimiter unless type.nil? + + lines + end + end + end +end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index cf097e0d0de..80a146b4a5a 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -2,11 +2,13 @@ module Gitlab module Diff class Line attr_reader :type, :index, :old_pos, :new_pos + attr_writer :rich_text attr_accessor :text - def initialize(text, type, index, old_pos, new_pos) + def initialize(text, type, index, old_pos, new_pos, parent_file: nil) @text, @type, @index = text, type, index @old_pos, @new_pos = old_pos, new_pos + @parent_file = parent_file end def self.init_from_hash(hash) @@ -43,9 +45,25 @@ module Gitlab type == 'old' end + def rich_text + @parent_file.highlight_lines! if @parent_file && !@rich_text + + @rich_text + end + def meta? type == 'match' || type == 'nonewline' end + + def as_json(opts = nil) + { + type: type, + old_line: old_line, + new_line: new_line, + text: text, + rich_text: rich_text || text + } + end end end end diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb index 41fcd971c22..3d1ba33ec68 100644 --- a/lib/gitlab/metrics.rb +++ b/lib/gitlab/metrics.rb @@ -124,6 +124,15 @@ module Gitlab trans.action = action if trans end + # Tracks an event. + # + # See `Gitlab::Metrics::Transaction#add_event` for more details. + def self.add_event(*args) + trans = current_transaction + + trans.add_event(*args) if trans + end + # Returns the prefix to use for the name of a series. def self.series_prefix @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' diff --git a/lib/gitlab/metrics/metric.rb b/lib/gitlab/metrics/metric.rb index f23d67e1e38..bd0afe53c51 100644 --- a/lib/gitlab/metrics/metric.rb +++ b/lib/gitlab/metrics/metric.rb @@ -4,15 +4,20 @@ module Gitlab class Metric JITTER_RANGE = 0.000001..0.001 - attr_reader :series, :values, :tags + attr_reader :series, :values, :tags, :type # series - The name of the series (as a String) to store the metric in. # values - A Hash containing the values to store. # tags - A Hash containing extra tags to add to the metrics. - def initialize(series, values, tags = {}) + def initialize(series, values, tags = {}, type = :metric) @values = values @series = series @tags = tags + @type = type + end + + def event? + type == :event end # Returns a Hash in a format that can be directly written to InfluxDB. diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index e61670f491c..b4493bf44d2 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -17,6 +17,10 @@ module Gitlab begin retval = trans.run { @app.call(env) } + rescue Exception => error # rubocop: disable Lint/RescueException + trans.add_event(:rails_exception) + + raise error # Even in the event of an error we want to submit any metrics we # might've gathered up to this point. ensure diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb index a1240fd33ee..f9dd8e41912 100644 --- a/lib/gitlab/metrics/sidekiq_middleware.rb +++ b/lib/gitlab/metrics/sidekiq_middleware.rb @@ -11,6 +11,10 @@ module Gitlab # Old gitlad-shell messages don't provide enqueued_at/created_at attributes trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0)) trans.run { yield } + rescue Exception => error # rubocop: disable Lint/RescueException + trans.add_event(:sidekiq_exception) + + raise error ensure trans.finish end diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index 968f3218950..7bc16181be6 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -4,7 +4,10 @@ module Gitlab class Transaction THREAD_KEY = :_gitlab_metrics_transaction - attr_reader :tags, :values, :methods + # The series to store events (e.g. Git pushes) in. + EVENT_SERIES = 'events' + + attr_reader :tags, :values, :method, :metrics attr_accessor :action @@ -55,6 +58,20 @@ module Gitlab @metrics << Metric.new("#{Metrics.series_prefix}#{series}", values, tags) end + # Tracks a business level event + # + # Business level events including events such as Git pushes, Emails being + # sent, etc. + # + # event_name - The name of the event (e.g. "git_push"). + # tags - A set of tags to attach to the event. + def add_event(event_name, tags = {}) + @metrics << Metric.new(EVENT_SERIES, + { count: 1 }, + { event: event_name }.merge(tags), + :event) + end + # Returns a MethodCall object for the given name. def method_call_for(name) unless method = @methods[name] @@ -101,7 +118,7 @@ module Gitlab submit_hashes = submit.map do |metric| hash = metric.to_hash - hash[:tags][:action] ||= @action if @action + hash[:tags][:action] ||= @action if @action && !metric.event? hash end diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb new file mode 100644 index 00000000000..d0ad5e26dbd --- /dev/null +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe Projects::Boards::IssuesController do + let(:project) { create(:project_with_board) } + let(:user) { create(:user) } + + let(:planning) { create(:label, project: project, name: 'Planning') } + let(:development) { create(:label, project: project, name: 'Development') } + + let!(:list1) { create(:list, board: project.board, label: planning, position: 0) } + let!(:list2) { create(:list, board: project.board, label: development, position: 1) } + + before do + project.team << [user, :master] + end + + describe 'GET index' do + context 'with valid list id' do + it 'returns issues that have the list label applied' do + johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png'))) + create(:labeled_issue, project: project, labels: [planning]) + create(:labeled_issue, project: project, labels: [development]) + create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + + list_issues user: user, list_id: list2 + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('issues') + expect(parsed_response.length).to eq 2 + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + list_issues user: user, list_id: 999 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + before do + allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability.abilities).to receive(:allowed?).with(user, :read_issue, project).and_return(false) + end + + it 'returns a successful 403 response' do + list_issues user: user, list_id: list2 + + expect(response).to have_http_status(403) + end + end + + def list_issues(user:, list_id:) + sign_in(user) + + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + list_id: list_id.to_param + end + end + + describe 'PATCH update' do + let(:issue) { create(:labeled_issue, project: project, labels: [planning]) } + + context 'with valid params' do + it 'returns a successful 200 response' do + move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(200) + end + + it 'moves issue to the desired list' do + move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(issue.reload.labels).to contain_exactly(development) + end + end + + context 'with invalid params' do + it 'returns a unprocessable entity 422 response for invalid lists' do + move user: user, issue: issue, from_list_id: nil, to_list_id: nil + + expect(response).to have_http_status(422) + end + + it 'returns a not found 404 response for invalid issue id' do + move user: user, issue: 999, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + let(:guest) { create(:user) } + + before do + project.team << [guest, :guest] + end + + it 'returns a successful 403 response' do + move user: guest, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(403) + end + end + + def move(user:, issue:, from_list_id:, to_list_id:) + sign_in(user) + + patch :update, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: issue.to_param, + from_list_id: from_list_id, + to_list_id: to_list_id, + format: :json + end + end +end diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb new file mode 100644 index 00000000000..9496636e3cc --- /dev/null +++ b/spec/controllers/projects/boards/lists_controller_spec.rb @@ -0,0 +1,241 @@ +require 'spec_helper' + +describe Projects::Boards::ListsController do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + let(:guest) { create(:user) } + + before do + project.team << [user, :master] + project.team << [guest, :guest] + end + + describe 'GET index' do + it 'returns a successful 200 response' do + read_board_list user: user + + expect(response).to have_http_status(200) + expect(response.content_type).to eq 'application/json' + end + + it 'returns a list of board lists' do + board = project.create_board + create(:backlog_list, board: board) + create(:list, board: board) + create(:done_list, board: board) + + read_board_list user: user + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('lists') + expect(parsed_response.length).to eq 3 + end + + context 'with unauthorized user' do + before do + allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability.abilities).to receive(:allowed?).with(user, :read_list, project).and_return(false) + end + + it 'returns a successful 403 response' do + read_board_list user: user + + expect(response).to have_http_status(403) + end + end + + def read_board_list(user:) + sign_in(user) + + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: :json + end + end + + describe 'POST create' do + let(:label) { create(:label, project: project, name: 'Development') } + + context 'with valid params' do + it 'returns a successful 200 response' do + create_board_list user: user, label_id: label.id + + expect(response).to have_http_status(200) + end + + it 'returns the created list' do + create_board_list user: user, label_id: label.id + + expect(response).to match_response_schema('list') + end + end + + context 'with invalid params' do + it 'returns an error' do + create_board_list user: user, label_id: nil + + parsed_response = JSON.parse(response.body) + + expect(parsed_response['label']).to contain_exactly "can't be blank" + expect(response).to have_http_status(422) + end + end + + context 'with unauthorized user' do + let(:label) { create(:label, project: project, name: 'Development') } + + it 'returns a successful 403 response' do + create_board_list user: guest, label_id: label.id + + expect(response).to have_http_status(403) + end + end + + def create_board_list(user:, label_id:) + sign_in(user) + + post :create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + list: { label_id: label_id }, + format: :json + end + end + + describe 'PATCH update' do + let!(:planning) { create(:list, board: board, position: 0) } + let!(:development) { create(:list, board: board, position: 1) } + + context 'with valid position' do + it 'returns a successful 200 response' do + move user: user, list: planning, position: 1 + + expect(response).to have_http_status(200) + end + + it 'moves the list to the desired position' do + move user: user, list: planning, position: 1 + + expect(planning.reload.position).to eq 1 + end + end + + context 'with invalid position' do + it 'returns a unprocessable entity 422 response' do + move user: user, list: planning, position: 6 + + expect(response).to have_http_status(422) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + move user: user, list: 999, position: 1 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + it 'returns a successful 403 response' do + move user: guest, list: planning, position: 6 + + expect(response).to have_http_status(403) + end + end + + def move(user:, list:, position:) + sign_in(user) + + patch :update, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: list.to_param, + list: { position: position }, + format: :json + end + end + + describe 'DELETE destroy' do + let!(:planning) { create(:list, board: board, position: 0) } + + context 'with valid list id' do + it 'returns a successful 200 response' do + remove_board_list user: user, list: planning + + expect(response).to have_http_status(200) + end + + it 'removes list from board' do + expect { remove_board_list user: user, list: planning }.to change(board.lists, :size).by(-1) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + remove_board_list user: user, list: 999 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + it 'returns a successful 403 response' do + remove_board_list user: guest, list: planning + + expect(response).to have_http_status(403) + end + end + + def remove_board_list(user:, list:) + sign_in(user) + + delete :destroy, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: list.to_param, + format: :json + end + end + + describe 'POST generate' do + context 'when board lists is empty' do + it 'returns a successful 200 response' do + generate_default_board_lists user: user + + expect(response).to have_http_status(200) + end + + it 'returns the defaults lists' do + generate_default_board_lists user: user + + expect(response).to match_response_schema('lists') + end + end + + context 'when board lists is not empty' do + it 'returns a unprocessable entity 422 response' do + create(:list, board: board) + + generate_default_board_lists user: user + + expect(response).to have_http_status(422) + end + end + + context 'with unauthorized user' do + it 'returns a successful 403 response' do + generate_default_board_lists user: guest + + expect(response).to have_http_status(403) + end + end + + def generate_default_board_lists(user:) + sign_in(user) + + post :generate, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: :json + end + end +end diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb new file mode 100644 index 00000000000..75a6d39e82c --- /dev/null +++ b/spec/controllers/projects/boards_controller_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Projects::BoardsController do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + sign_in(user) + end + + describe 'GET show' do + it 'creates a new board when project does not have one' do + expect { read_board }.to change(Board, :count).by(1) + end + + it 'renders HTML template' do + read_board + + expect(response).to render_template :show + expect(response.content_type).to eq 'text/html' + end + + context 'with unauthorized user' do + before do + allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability.abilities).to receive(:allowed?).with(user, :read_board, project).and_return(false) + end + + it 'returns a successful 404 response' do + read_board + + expect(response).to have_http_status(404) + end + end + + def read_board(format: :html) + get :show, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: format + end + end +end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 69758494543..c64c2b075c5 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do let(:project) { create(:project) } let(:user) { create(:user) } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } + let(:merge_request_with_conflicts) do + create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end before do sign_in(user) @@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do end end end + + describe 'GET conflicts' do + let(:json_response) { JSON.parse(response.body) } + + context 'when the conflicts cannot be resolved in the UI' do + before do + allow_any_instance_of(Gitlab::Conflict::Parser). + to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + get :conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'returns a 200 status code' do + expect(response).to have_http_status(:ok) + end + + it 'returns JSON with a message' do + expect(json_response.keys).to contain_exactly('message', 'type') + end + end + + context 'with valid conflicts' do + before do + get :conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'includes meta info about the MR' do + expect(json_response['commit_message']).to include('Merge branch') + expect(json_response['commit_sha']).to match(/\h{40}/) + expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch) + expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch) + end + + it 'includes each file that has conflicts' do + filenames = json_response['files'].map { |file| file['new_path'] } + + expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb') + end + + it 'splits files into sections with lines' do + json_response['files'].each do |file| + file['sections'].each do |section| + expect(section).to include('conflict', 'lines') + + section['lines'].each do |line| + if section['conflict'] + expect(line['type']).to be_in(['old', 'new']) + expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer)) + else + if line['type'].nil? + expect(line['old_line']).not_to eq(nil) + expect(line['new_line']).not_to eq(nil) + else + expect(line['type']).to eq('match') + expect(line['old_line']).to eq(nil) + expect(line['new_line']).to eq(nil) + end + end + end + end + end + end + + it 'has unique section IDs across files' do + section_ids = json_response['files'].flat_map do |file| + file['sections'].map { |section| section['id'] }.compact + end + + expect(section_ids.uniq).to eq(section_ids) + end + end + end + + context 'POST resolve_conflicts' do + let(:json_response) { JSON.parse(response.body) } + let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha } + + def resolve_conflicts(sections) + post :resolve_conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json', + sections: sections, + commit_message: 'Commit message' + end + + context 'with valid params' do + before do + resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin') + end + + it 'creates a new commit on the branch' do + expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha) + expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message') + end + + it 'returns an OK response' do + expect(response).to have_http_status(:ok) + end + end + + context 'when sections are missing' do + before do + resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head') + end + + it 'returns a 400 error' do + expect(response).to have_http_status(:bad_request) + end + + it 'has a message with the name of the first missing section' do + expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9') + end + + it 'does not create a new commit' do + expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) + end + end + end end diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb new file mode 100644 index 00000000000..35c4a0b6f08 --- /dev/null +++ b/spec/factories/boards.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :board do + project factory: :empty_project + end +end diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb new file mode 100644 index 00000000000..9e3f06c682c --- /dev/null +++ b/spec/factories/lists.rb @@ -0,0 +1,20 @@ +FactoryGirl.define do + factory :list do + board + label + list_type :label + sequence(:position) + end + + factory :backlog_list, parent: :list do + list_type :backlog + label nil + position nil + end + + factory :done_list, parent: :list do + list_type :done + label nil + position nil + end +end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index b682ced75ac..f82d68a1816 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -83,4 +83,10 @@ FactoryGirl.define do ) end end + + factory :project_with_board, parent: :empty_project do + after(:create) do |project| + project.create_board + end + end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb new file mode 100644 index 00000000000..e4c5a10ce7e --- /dev/null +++ b/spec/features/boards/boards_spec.rb @@ -0,0 +1,598 @@ +require 'rails_helper' + +describe 'Issue Boards', feature: true, js: true do + include WaitForAjax + + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + let!(:user2) { create(:user) } + + before do + project.create_board + project.board.lists.create(list_type: :backlog) + project.board.lists.create(list_type: :done) + + project.team << [user, :master] + project.team << [user2, :master] + + login_as(user) + end + + context 'no lists' do + before do + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + expect(page).to have_selector('.board', count: 3) + end + + it 'shows blank state' do + expect(page).to have_content('Welcome to your Issue Board!') + end + + it 'hides the blank state when clicking nevermind button' do + page.within(find('.board-blank-state')) do + click_button("Nevermind, I'll use my own") + end + expect(page).to have_selector('.board', count: 2) + end + + it 'creates default lists' do + lists = ['Backlog', 'Development', 'Testing', 'Production', 'Ready', 'Done'] + + page.within(find('.board-blank-state')) do + click_button('Add default lists') + end + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 6) + + page.all('.board').each_with_index do |list, i| + expect(list.find('.board-title')).to have_content(lists[i]) + end + end + end + + context 'with lists' do + let(:milestone) { create(:milestone, project: project) } + + let(:planning) { create(:label, project: project, name: 'Planning') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + let(:bug) { create(:label, project: project, name: 'Bug') } + let!(:backlog) { create(:label, project: project, name: 'Backlog') } + let!(:done) { create(:label, project: project, name: 'Done') } + + let!(:list1) { create(:list, board: project.board, label: planning, position: 0) } + let!(:list2) { create(:list, board: project.board, label: development, position: 1) } + + let!(:confidential_issue) { create(:issue, :confidential, project: project, author: user) } + let!(:issue1) { create(:issue, project: project, assignee: user) } + let!(:issue2) { create(:issue, project: project, author: user2) } + let!(:issue3) { create(:issue, project: project) } + let!(:issue4) { create(:issue, project: project) } + let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) } + let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) } + let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) } + let!(:issue8) { create(:closed_issue, project: project) } + let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug]) } + + before do + visit namespace_project_board_path(project.namespace, project) + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 4) + expect(find('.board:nth-child(1)')).to have_selector('.card') + expect(find('.board:nth-child(2)')).to have_selector('.card') + expect(find('.board:nth-child(3)')).to have_selector('.card') + expect(find('.board:nth-child(4)')).to have_selector('.card') + end + + it 'shows lists' do + expect(page).to have_selector('.board', count: 4) + end + + it 'shows issues in lists' do + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('2') + expect(page).to have_selector('.card', count: 2) + end + + page.within(find('.board:nth-child(3)')) do + expect(page.find('.board-header')).to have_content('2') + expect(page).to have_selector('.card', count: 2) + end + end + + it 'shows confidential issues with icon' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.confidential-icon', count: 1) + end + end + + it 'allows user to delete board' do + page.within(find('.board:nth-child(2)')) do + find('.board-delete').click + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + end + + it 'removes checkmark in new list dropdown after deleting' do + click_button 'Create new list' + wait_for_ajax + + page.within(find('.board:nth-child(2)')) do + find('.board-delete').click + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + expect(find(".js-board-list-#{planning.id}", visible: false)).not_to have_css('.is-active') + end + + it 'infinite scrolls list' do + 50.times do + create(:issue, project: project) + end + + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('20') + expect(page).to have_selector('.card', count: 20) + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + + expect(page.find('.board-header')).to have_content('40') + expect(page).to have_selector('.card', count: 40) + end + end + + context 'backlog' do + it 'shows issues in backlog with no labels' do + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('6') + expect(page).to have_selector('.card', count: 6) + end + end + + it 'is searchable' do + page.within(find('.board', match: :first)) do + find('.form-control').set issue1.title + + expect(page).to have_selector('.card', count: 1) + end + end + + it 'clears search' do + page.within(find('.board', match: :first)) do + find('.form-control').set issue1.title + + expect(page).to have_selector('.card', count: 1) + + find('.board-search-clear-btn').click + + expect(page).to have_selector('.card', count: 6) + end + end + + it 'moves issue from backlog into list' do + drag_to(list_to_index: 1) + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('5') + expect(page).to have_selector('.card', count: 5) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('3') + expect(page).to have_selector('.card', count: 3) + end + end + end + + context 'done' do + it 'shows list of done issues' do + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1) + end + + it 'moves issue to done' do + drag_to(list_from_index: 0, list_to_index: 3) + + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2) + expect(find('.board:nth-child(4)')).to have_content(issue9.title) + expect(find('.board:nth-child(4)')).not_to have_content(planning.title) + end + + it 'removes all of the same issue to done' do + drag_to(list_from_index: 1, list_to_index: 3) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(4)')).to have_content(issue6.title) + expect(find('.board:nth-child(4)')).not_to have_content(planning.title) + end + end + + context 'lists' do + it 'changes position of list' do + drag_to(list_from_index: 1, list_to_index: 2, selector: '.board-header') + + expect(find('.board:nth-child(2)')).to have_content(development.title) + expect(find('.board:nth-child(2)')).to have_content(planning.title) + end + + it 'issue moves between lists' do + drag_to(list_from_index: 1, card_index: 1, list_to_index: 2) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 3) + expect(find('.board:nth-child(3)')).to have_content(issue6.title) + expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title) + end + + it 'issue moves between lists' do + drag_to(list_from_index: 2, list_to_index: 1) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(2)')).to have_content(issue7.title) + expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title) + end + + it 'issue moves from done' do + drag_to(list_from_index: 3, list_to_index: 1) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3) + expect(find('.board:nth-child(2)')).to have_content(issue8.title) + end + + context 'issue card' do + it 'shows assignee' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.avatar', count: 1) + end + end + end + + context 'new list' do + it 'shows all labels in new list dropdown' do + click_button 'Create new list' + + page.within('.dropdown-menu-issues-board-new') do + expect(page).to have_content(planning.title) + expect(page).to have_content(development.title) + expect(page).to have_content(testing.title) + end + end + + it 'creates new list for label' do + click_button 'Create new list' + + page.within('.dropdown-menu-issues-board-new') do + click_link testing.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'creates new list for Backlog label' do + click_button 'Create new list' + + page.within('.dropdown-menu-issues-board-new') do + click_link backlog.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'creates new list for Done label' do + click_button 'Create new list' + + page.within('.dropdown-menu-issues-board-new') do + click_link done.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'moves issues from backlog into new list' do + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('6') + expect(page).to have_selector('.card', count: 6) + end + + click_button 'Create new list' + + page.within('.dropdown-menu-issues-board-new') do + click_link testing.title + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('5') + expect(page).to have_selector('.card', count: 5) + end + end + end + end + + context 'filtering' do + it 'filters by author' do + page.within '.issues-filters' do + click_button('Author') + + page.within '.dropdown-menu-author' do + click_link(user2.name) + end + wait_for_vue_resource(spinner: false) + + expect(find('.js-author-search')).to have_content(user2.name) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by assignee' do + page.within '.issues-filters' do + click_button('Assignee') + + page.within '.dropdown-menu-assignee' do + click_link(user.name) + end + wait_for_vue_resource(spinner: false) + + expect(find('.js-assignee-search')).to have_content(user.name) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by milestone' do + page.within '.issues-filters' do + click_button('Milestone') + + page.within '.milestone-filter' do + click_link(milestone.title) + end + wait_for_vue_resource(spinner: false) + + expect(find('.js-milestone-select')).to have_content(milestone.title) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + end + + it 'filters by label' do + page.within '.issues-filters' do + click_button('Label') + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'infinite scrolls list with label filter' do + 50.times do + create(:labeled_issue, project: project, labels: [testing]) + end + + page.within '.issues-filters' do + click_button('Label') + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('20') + expect(page).to have_selector('.card', count: 20) + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + + expect(page.find('.board-header')).to have_content('40') + expect(page).to have_selector('.card', count: 40) + end + end + + it 'filters by multiple labels' do + page.within '.issues-filters' do + click_button('Label') + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + click_link(bug.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by no label' do + page.within '.issues-filters' do + click_button('Label') + + page.within '.dropdown-menu-labels' do + click_link("No Label") + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('5') + expect(page).to have_selector('.card', count: 5) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by clicking label button on issue' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.card', count: 6) + click_button(bug.title) + wait_for_vue_resource(spinner: false) + end + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + + page.within('.labels-filter') do + expect(find('.dropdown-toggle-text')).to have_content(bug.title) + end + end + + it 'removes label filter by clicking label button on issue' do + page.within(find('.board', match: :first)) do + page.within(find('.card', match: :first)) do + click_button(bug.title) + end + wait_for_vue_resource(spinner: false) + + expect(page).to have_selector('.card', count: 1) + end + + wait_for_vue_resource + + page.within('.labels-filter') do + expect(find('.dropdown-toggle-text')).to have_content(bug.title) + end + end + end + end + + context 'signed out user' do + before do + logout + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + end + + it 'does not show create new list' do + expect(page).not_to have_selector('.js-new-board-list') + end + end + + context 'as guest user' do + let(:user_guest) { create(:user) } + + before do + project.team << [user_guest, :guest] + logout + login_as(user_guest) + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + end + + it 'does not show create new list' do + expect(page).not_to have_selector('.js-new-board-list') + end + end + + def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list') + evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});") + + Timeout.timeout(Capybara.default_max_wait_time) do + loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero? + end + + wait_for_vue_resource + end + + def wait_for_vue_resource(spinner: true) + Timeout.timeout(Capybara.default_max_wait_time) do + loop until page.evaluate_script('Vue.activeResources').zero? + end + + if spinner + expect(find('.boards-list')).not_to have_selector('.fa-spinner') + end + end +end diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb new file mode 100644 index 00000000000..930c36ade2b --- /dev/null +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +feature 'Merge request conflict resolution', js: true, feature: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project) } + + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end + + context 'when a merge request can be resolved in the UI' do + let(:merge_request) { create_merge_request('conflict-resolvable') } + + before do + project.team << [user, :developer] + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows a link to the conflict resolution page' do + expect(page).to have_link('conflicts', href: /\/conflicts\Z/) + end + + context 'visiting the conflicts resolution page' do + before { click_link('conflicts', href: /\/conflicts\Z/) } + + it 'shows the conflicts' do + begin + expect(find('#conflicts')).to have_content('popen.rb') + rescue Capybara::Poltergeist::JavascriptError + retry + end + end + end + end + + UNRESOLVABLE_CONFLICTS = { + 'conflict-too-large' => 'when the conflicts contain a large file', + 'conflict-binary-file' => 'when the conflicts contain a binary file', + 'conflict-contains-conflict-markers' => 'when the conflicts contain a file with ambiguous conflict markers', + 'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another' + } + + UNRESOLVABLE_CONFLICTS.each do |source_branch, description| + context description do + let(:merge_request) { create_merge_request(source_branch) } + + before do + project.team << [user, :developer] + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to the conflict resolution page' do + expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/) + end + + it 'shows an error if the conflicts page is visited directly' do + visit current_url + '/conflicts' + wait_for_ajax + + expect(find('#conflicts')).to have_content('Please try to resolve them locally.') + end + end + end +end diff --git a/spec/features/merge_requests/pipelines_spec.rb b/spec/features/merge_requests/pipelines_spec.rb new file mode 100644 index 00000000000..9c4c0525267 --- /dev/null +++ b/spec/features/merge_requests/pipelines_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +feature 'Pipelines for Merge Requests', feature: true, js: true do + include WaitForAjax + + given(:user) { create(:user) } + given(:merge_request) { create(:merge_request) } + given(:project) { merge_request.target_project } + + before do + project.team << [user, :master] + login_as user + end + + context 'with pipelines' do + let!(:pipeline) do + create(:ci_empty_pipeline, + project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + scenario 'user visits merge request pipelines tab' do + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + wait_for_ajax + + expect(page).to have_selector('.pipeline-actions') + end + end + + context 'without pipelines' do + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + scenario 'user visits merge request page' do + page.within('.merge-request-tabs') do + expect(page).to have_no_link('Pipelines') + end + end + end +end diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json new file mode 100644 index 00000000000..299e4675d6f --- /dev/null +++ b/spec/fixtures/api/schemas/issue.json @@ -0,0 +1,40 @@ +{ + "type": "object", + "required" : [ + "iid", + "title", + "confidential" + ], + "properties" : { + "iid": { "type": "integer" }, + "title": { "type": "string" }, + "confidential": { "type": "boolean" }, + "labels": { + "type": ["array"], + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + } + }, + "assignee": { + "id": { "type": "integet" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/issues.json b/spec/fixtures/api/schemas/issues.json new file mode 100644 index 00000000000..0d2067f704a --- /dev/null +++ b/spec/fixtures/api/schemas/issues.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "issue.json" } +} diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json new file mode 100644 index 00000000000..f070fa3b254 --- /dev/null +++ b/spec/fixtures/api/schemas/list.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "required" : [ + "id", + "list_type", + "title", + "position" + ], + "properties" : { + "id": { "type": "integer" }, + "list_type": { + "type": "string", + "enum": ["backlog", "label", "done"] + }, + "label": { + "type": ["object"], + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + } + }, + "title": { "type": "string" }, + "position": { "type": ["integer", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/lists.json b/spec/fixtures/api/schemas/lists.json new file mode 100644 index 00000000000..9f618aa9de5 --- /dev/null +++ b/spec/fixtures/api/schemas/lists.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "list.json" } +} diff --git a/spec/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js.es6 new file mode 100644 index 00000000000..078e4b00023 --- /dev/null +++ b/spec/javascripts/boards/boards_store_spec.js.es6 @@ -0,0 +1,164 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +(() => { + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board'); + gl.issueBoards.BoardsStore.create(); + + $.cookie('issue_board_welcome_hidden', 'false'); + }); + + describe('Store', () => { + it('starts with a blank state', () => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + }); + + describe('lists', () => { + it('creates new list without persisting to DB', () => { + gl.issueBoards.BoardsStore.addList(listObj); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + }); + + it('finds list by ID', () => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('id', 1); + + expect(list.id).toBe(1); + }); + + it('finds list by type', () => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('type', 'label'); + + expect(list).toBeDefined(); + }); + + it('finds list limited by type', () => { + gl.issueBoards.BoardsStore.addList({ + id: 1, + position: 0, + title: 'Test', + list_type: 'backlog' + }); + const list = gl.issueBoards.BoardsStore.findList('id', 1, 'backlog'); + + expect(list).toBeDefined(); + }); + + it('gets issue when new list added', (done) => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('id', 1); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + setTimeout(() => { + expect(list.issues.length).toBe(1); + expect(list.issues[0].id).toBe(1); + done(); + }, 0); + }); + + it('persists new list', (done) => { + gl.issueBoards.BoardsStore.new({ + title: 'Test', + type: 'label', + label: { + id: 1, + title: 'Testing', + color: 'red', + description: 'testing;' + } + }); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + setTimeout(() => { + const list = gl.issueBoards.BoardsStore.findList('id', 1); + expect(list).toBeDefined(); + expect(list.id).toBe(1); + expect(list.position).toBe(0); + done(); + }, 0); + }); + + it('check for blank state adding', () => { + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true); + }); + + it('check for blank state not adding', () => { + gl.issueBoards.BoardsStore.addList(listObj); + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(false); + }); + + it('check for blank state adding when backlog & done list exist', () => { + gl.issueBoards.BoardsStore.addList({ + list_type: 'backlog' + }); + gl.issueBoards.BoardsStore.addList({ + list_type: 'done' + }); + + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true); + }); + + it('adds the blank state', () => { + gl.issueBoards.BoardsStore.addBlankState(); + + const list = gl.issueBoards.BoardsStore.findList('type', 'blank', 'blank'); + expect(list).toBeDefined(); + }); + + it('removes list from state', () => { + gl.issueBoards.BoardsStore.addList(listObj); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + gl.issueBoards.BoardsStore.removeList(1, 'label'); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + }); + + it('moves the position of lists', () => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj), + listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + gl.issueBoards.BoardsStore.moveList(listOne, ['2', '1']); + + expect(listOne.position).toBe(1); + }); + + it('moves an issue from one list to another', (done) => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj), + listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + setTimeout(() => { + expect(listOne.issues.length).toBe(1); + expect(listTwo.issues.length).toBe(1); + + gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1)); + + expect(listOne.issues.length).toBe(0); + expect(listTwo.issues.length).toBe(1); + + done(); + }, 0); + }); + }); + }); +})(); diff --git a/spec/javascripts/boards/issue_spec.js.es6 b/spec/javascripts/boards/issue_spec.js.es6 new file mode 100644 index 00000000000..3569d1b98bd --- /dev/null +++ b/spec/javascripts/boards/issue_spec.js.es6 @@ -0,0 +1,83 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +describe('Issue model', () => { + let issue; + + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board'); + gl.issueBoards.BoardsStore.create(); + + issue = new ListIssue({ + title: 'Testing', + iid: 1, + confidential: false, + labels: [{ + id: 1, + title: 'test', + color: 'red', + description: 'testing' + }] + }); + }); + + it('has label', () => { + expect(issue.labels.length).toBe(1); + }); + + it('add new label', () => { + issue.addLabel({ + id: 2, + title: 'bug', + color: 'blue', + description: 'bugs!' + }); + expect(issue.labels.length).toBe(2); + }); + + it('does not add existing label', () => { + issue.addLabel({ + id: 2, + title: 'test', + color: 'blue', + description: 'bugs!' + }); + + expect(issue.labels.length).toBe(1); + }); + + it('finds label', () => { + const label = issue.findLabel(issue.labels[0]); + expect(label).toBeDefined(); + }); + + it('removes label', () => { + const label = issue.findLabel(issue.labels[0]); + issue.removeLabel(label); + expect(issue.labels.length).toBe(0); + }); + + it('removes multiple labels', () => { + issue.addLabel({ + id: 2, + title: 'bug', + color: 'blue', + description: 'bugs!' + }); + expect(issue.labels.length).toBe(2); + + issue.removeLabels([issue.labels[0], issue.labels[1]]); + expect(issue.labels.length).toBe(0); + }); +}); diff --git a/spec/javascripts/boards/list_spec.js.es6 b/spec/javascripts/boards/list_spec.js.es6 new file mode 100644 index 00000000000..c206b794442 --- /dev/null +++ b/spec/javascripts/boards/list_spec.js.es6 @@ -0,0 +1,89 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +describe('List model', () => { + let list; + + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board'); + gl.issueBoards.BoardsStore.create(); + + list = new List(listObj); + }); + + it('gets issues when created', (done) => { + setTimeout(() => { + expect(list.issues.length).toBe(1); + done(); + }, 0); + }); + + it('saves list and returns ID', (done) => { + list = new List({ + title: 'test', + label: { + id: 1, + title: 'test', + color: 'red' + } + }); + list.save(); + + setTimeout(() => { + expect(list.id).toBe(1); + expect(list.type).toBe('label'); + expect(list.position).toBe(0); + done(); + }, 0); + }); + + it('destroys the list', (done) => { + gl.issueBoards.BoardsStore.addList(listObj); + list = gl.issueBoards.BoardsStore.findList('id', 1); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + list.destroy(); + + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + done(); + }, 0); + }); + + it('can\'t search when not backlog', () => { + expect(list.canSearch()).toBe(false); + }); + + it('can search when backlog', () => { + list.type = 'backlog'; + expect(list.canSearch()).toBe(true); + }); + + it('gets issue from list', (done) => { + setTimeout(() => { + const issue = list.findIssue(1); + expect(issue).toBeDefined(); + done(); + }, 0); + }); + + it('removes issue', (done) => { + setTimeout(() => { + const issue = list.findIssue(1); + expect(list.issues.length).toBe(1); + list.removeIssue(issue); + expect(list.issues.length).toBe(0); + done(); + }, 0); + }); +}); diff --git a/spec/javascripts/boards/mock_data.js.es6 b/spec/javascripts/boards/mock_data.js.es6 new file mode 100644 index 00000000000..0c37ec8354f --- /dev/null +++ b/spec/javascripts/boards/mock_data.js.es6 @@ -0,0 +1,53 @@ +const listObj = { + id: 1, + position: 0, + title: 'Test', + list_type: 'label', + label: { + id: 1, + title: 'Testing', + color: 'red', + description: 'testing;' + } +}; + +const listObjDuplicate = { + id: 2, + position: 1, + title: 'Test', + list_type: 'label', + label: { + id: 2, + title: 'Testing', + color: 'red', + description: 'testing;' + } +}; + +const BoardsMockData = { + 'GET': { + '/test/issue-boards/board/lists{/id}/issues': [{ + title: 'Testing', + iid: 1, + confidential: false, + labels: [] + }] + }, + 'POST': { + '/test/issue-boards/board/lists{/id}': listObj + }, + 'PUT': { + '/test/issue-boards/board/lists{/id}': {} + }, + 'DELETE': { + '/test/issue-boards/board/lists{/id}': {} + } +}; + +Vue.http.interceptors.push((request, next) => { + const body = BoardsMockData[request.method][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200 + })); +}); diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb new file mode 100644 index 00000000000..39d892c18c0 --- /dev/null +++ b/spec/lib/gitlab/conflict/file_collection_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Gitlab::Conflict::FileCollection, lib: true do + let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') } + let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) } + + describe '#files' do + it 'returns an array of Conflict::Files' do + expect(file_collection.files).to all(be_an_instance_of(Gitlab::Conflict::File)) + end + end + + describe '#default_commit_message' do + it 'matches the format of the git CLI commit message' do + expect(file_collection.default_commit_message).to eq(<<EOM.chomp) +Merge branch 'conflict-start' into 'conflict-resolvable' + +# Conflicts: +# files/ruby/popen.rb +# files/ruby/regex.rb +EOM + end + end +end diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb new file mode 100644 index 00000000000..60020487061 --- /dev/null +++ b/spec/lib/gitlab/conflict/file_spec.rb @@ -0,0 +1,261 @@ +require 'spec_helper' + +describe Gitlab::Conflict::File, lib: true do + let(:project) { create(:project) } + let(:repository) { project.repository } + let(:rugged) { repository.rugged } + let(:their_commit) { rugged.branches['conflict-start'].target } + let(:our_commit) { rugged.branches['conflict-resolvable'].target } + let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) } + let(:index) { rugged.merge_commits(our_commit, their_commit) } + let(:conflict) { index.conflicts.last } + let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') } + let(:conflict_file) { Gitlab::Conflict::File.new(merge_file_result, conflict, merge_request: merge_request) } + + describe '#resolve_lines' do + let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact } + + context 'when resolving everything to the same side' do + let(:resolution_hash) { section_keys.map { |key| [key, 'head'] }.to_h } + let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) } + let(:expected_lines) { conflict_file.lines.reject { |line| line.type == 'old' } } + + it 'has the correct number of lines' do + expect(resolved_lines.length).to eq(expected_lines.length) + end + + it 'has content matching the chosen lines' do + expect(resolved_lines.map(&:text)).to eq(expected_lines.map(&:text)) + end + end + + context 'with mixed resolutions' do + let(:resolution_hash) do + section_keys.map.with_index { |key, i| [key, i.even? ? 'head' : 'origin'] }.to_h + end + + let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) } + + it 'has the correct number of lines' do + file_lines = conflict_file.lines.reject { |line| line.type == 'new' } + + expect(resolved_lines.length).to eq(file_lines.length) + end + + it 'returns a file containing only the chosen parts of the resolved sections' do + expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)). + to eq(['both', 'new', 'both', 'old', 'both', 'new', 'both']) + end + end + + it 'raises MissingResolution when passed a hash without resolutions for all sections' do + empty_hash = section_keys.map { |key| [key, nil] }.to_h + invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h + + expect { conflict_file.resolve_lines({}) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + + expect { conflict_file.resolve_lines(empty_hash) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + + expect { conflict_file.resolve_lines(invalid_hash) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + end + end + + describe '#highlight_lines!' do + def html_to_text(html) + CGI.unescapeHTML(ActionView::Base.full_sanitizer.sanitize(html)).delete("\n") + end + + it 'modifies the existing lines' do + expect { conflict_file.highlight_lines! }.to change { conflict_file.lines.map(&:instance_variables) } + end + + it 'is called implicitly when rich_text is accessed on a line' do + expect(conflict_file).to receive(:highlight_lines!).once.and_call_original + + conflict_file.lines.each(&:rich_text) + end + + it 'sets the rich_text of the lines matching the text content' do + conflict_file.lines.each do |line| + expect(line.text).to eq(html_to_text(line.rich_text)) + end + end + end + + describe '#sections' do + it 'only inserts match lines when there is a gap between sections' do + conflict_file.sections.each_with_index do |section, i| + previous_line_number = 0 + current_line_number = section[:lines].map(&:old_line).compact.min + + if i > 0 + previous_line_number = conflict_file.sections[i - 1][:lines].map(&:old_line).compact.last + end + + if current_line_number == previous_line_number + 1 + expect(section[:lines].first.type).not_to eq('match') + else + expect(section[:lines].first.type).to eq('match') + expect(section[:lines].first.text).to match(/\A@@ -#{current_line_number},\d+ \+\d+,\d+ @@ module Gitlab\Z/) + end + end + end + + it 'sets conflict to false for sections with only unchanged lines' do + conflict_file.sections.reject { |section| section[:conflict] }.each do |section| + without_match = section[:lines].reject { |line| line.type == 'match' } + + expect(without_match).to all(have_attributes(type: nil)) + end + end + + it 'only includes a maximum of CONTEXT_LINES (plus an optional match line) in context sections' do + conflict_file.sections.reject { |section| section[:conflict] }.each do |section| + without_match = section[:lines].reject { |line| line.type == 'match' } + + expect(without_match.length).to be <= Gitlab::Conflict::File::CONTEXT_LINES * 2 + end + end + + it 'sets conflict to true for sections with only changed lines' do + conflict_file.sections.select { |section| section[:conflict] }.each do |section| + section[:lines].each do |line| + expect(line.type).to be_in(['new', 'old']) + end + end + end + + it 'adds unique IDs to conflict sections, and not to other sections' do + section_ids = [] + + conflict_file.sections.each do |section| + if section[:conflict] + expect(section).to have_key(:id) + section_ids << section[:id] + else + expect(section).not_to have_key(:id) + end + end + + expect(section_ids.uniq).to eq(section_ids) + end + + context 'with an example file' do + let(:file) do + <<FILE + # Ensure there is no match line header here + def username_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb +def project_name_regexp + /\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/ +end + +def name_regexp + /\A[a-zA-Z0-9_\-\. ]*\z/ +======= +def project_name_regex + %r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z} +end + +def name_regex + %r{\A[a-zA-Z0-9_\-\. ]*\z} +>>>>>>> files/ruby/regex.rb +end + +# Some extra lines +# To force a match line +# To be created + +def path_regexp + default_regexp +end + +<<<<<<< files/ruby/regex.rb +def archive_formats_regexp + /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/ +======= +def archive_formats_regex + %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)} +>>>>>>> files/ruby/regex.rb +end + +def git_reference_regexp + # Valid git ref regexp, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?<!\.lock) (?# rule #1) + (?<![\/.]) (?# rule #6-7) + }x +end + +protected + +<<<<<<< files/ruby/regex.rb +def default_regexp + /\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/ +======= +def default_regex + %r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z} +>>>>>>> files/ruby/regex.rb +end +FILE + end + + let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) } + let(:sections) { conflict_file.sections } + + it 'sets the correct match line headers' do + expect(sections[0][:lines].first).to have_attributes(type: 'match', text: '@@ -3,14 +3,14 @@') + expect(sections[3][:lines].first).to have_attributes(type: 'match', text: '@@ -19,26 +19,26 @@ def path_regexp') + expect(sections[6][:lines].first).to have_attributes(type: 'match', text: '@@ -47,52 +47,52 @@ end') + end + + it 'does not add match lines where they are not needed' do + expect(sections[1][:lines].first.type).not_to eq('match') + expect(sections[2][:lines].first.type).not_to eq('match') + expect(sections[4][:lines].first.type).not_to eq('match') + expect(sections[5][:lines].first.type).not_to eq('match') + expect(sections[7][:lines].first.type).not_to eq('match') + end + + it 'creates context sections of the correct length' do + expect(sections[0][:lines].reject(&:type).length).to eq(3) + expect(sections[2][:lines].reject(&:type).length).to eq(3) + expect(sections[3][:lines].reject(&:type).length).to eq(3) + expect(sections[5][:lines].reject(&:type).length).to eq(3) + expect(sections[6][:lines].reject(&:type).length).to eq(3) + expect(sections[8][:lines].reject(&:type).length).to eq(1) + end + end + end + + describe '#as_json' do + it 'includes the blob path for the file' do + expect(conflict_file.as_json[:blob_path]). + to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb") + end + + it 'includes the blob icon for the file' do + expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o') + end + end +end diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb new file mode 100644 index 00000000000..65a828accde --- /dev/null +++ b/spec/lib/gitlab/conflict/parser_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Gitlab::Conflict::Parser, lib: true do + let(:parser) { Gitlab::Conflict::Parser.new } + + describe '#parse' do + def parse_text(text) + parser.parse(text, our_path: 'README.md', their_path: 'README.md') + end + + context 'when the file has valid conflicts' do + let(:text) do + <<CONFLICT +module Gitlab + module Regexp + extend self + + def username_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb + def project_name_regexp + /\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/ + end + + def name_regexp + /\A[a-zA-Z0-9_\-\. ]*\z/ +======= + def project_name_regex + %r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z} + end + + def name_regex + %r{\A[a-zA-Z0-9_\-\. ]*\z} +>>>>>>> files/ruby/regex.rb + end + + def path_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb + def archive_formats_regexp + /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/ +======= + def archive_formats_regex + %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)} +>>>>>>> files/ruby/regex.rb + end + + def git_reference_regexp + # Valid git ref regexp, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?<!\.lock) (?# rule #1) + (?<![\/.]) (?# rule #6-7) + }x + end + + protected + +<<<<<<< files/ruby/regex.rb + def default_regexp + /\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/ +======= + def default_regex + %r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z} +>>>>>>> files/ruby/regex.rb + end + end +end +CONFLICT + end + + let(:lines) do + parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb') + end + + it 'sets our lines as new lines' do + expect(lines[8..13]).to all(have_attributes(type: 'new')) + expect(lines[26..27]).to all(have_attributes(type: 'new')) + expect(lines[56..57]).to all(have_attributes(type: 'new')) + end + + it 'sets their lines as old lines' do + expect(lines[14..19]).to all(have_attributes(type: 'old')) + expect(lines[28..29]).to all(have_attributes(type: 'old')) + expect(lines[58..59]).to all(have_attributes(type: 'old')) + end + + it 'sets non-conflicted lines as both' do + expect(lines[0..7]).to all(have_attributes(type: nil)) + expect(lines[20..25]).to all(have_attributes(type: nil)) + expect(lines[30..55]).to all(have_attributes(type: nil)) + expect(lines[60..62]).to all(have_attributes(type: nil)) + end + + it 'sets consecutive line numbers for index, old_pos, and new_pos' do + old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos) + new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos) + + expect(lines.map(&:index)).to eq(0.upto(62).to_a) + expect(old_line_numbers).to eq(1.upto(53).to_a) + expect(new_line_numbers).to eq(1.upto(53).to_a) + end + end + + context 'when the file contents include conflict delimiters' do + it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do + expect { parse_text('=======') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text('>>>>>>> README.md') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text('>>>>>>> some-other-path.md') }. + not_to raise_error + end + + it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do + start_text = "<<<<<<< README.md\n" + end_text = "\n=======\n>>>>>>> README.md" + + expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end + + it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do + start_text = "<<<<<<< README.md\n=======\n" + end_text = "\n>>>>>>> README.md" + + expect { parse_text(start_text + '=======' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end + + it 'raises MissingEndDelimiter when there is no end delimiter at the end' do + start_text = "<<<<<<< README.md\n=======\n" + + expect { parse_text(start_text) }. + to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md') }. + to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter) + end + end + + context 'other file types' do + it 'raises UnmergeableFile when lines is blank, indicating a binary file' do + expect { parse_text('') }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + + expect { parse_text(nil) }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + end + + it 'raises UnmergeableFile when the file is over 100 KB' do + expect { parse_text('a' * 102401) }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + end + end + end +end diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb index f718d536130..f26fca52c50 100644 --- a/spec/lib/gitlab/metrics/metric_spec.rb +++ b/spec/lib/gitlab/metrics/metric_spec.rb @@ -23,6 +23,24 @@ describe Gitlab::Metrics::Metric do it { is_expected.to eq({ host: 'localtoast' }) } end + describe '#type' do + subject { metric.type } + + it { is_expected.to eq(:metric) } + end + + describe '#event?' do + it 'returns false for a regular metric' do + expect(metric.event?).to eq(false) + end + + it 'returns true for an event metric' do + expect(metric).to receive(:type).and_return(:event) + + expect(metric.event?).to eq(true) + end + end + describe '#to_hash' do it 'returns a Hash' do expect(metric.to_hash).to be_an_instance_of(Hash) diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index f264ed64029..a30cb2a5e38 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -45,6 +45,15 @@ describe Gitlab::Metrics::RackMiddleware do middleware.call(env) end + + it 'tracks any raised exceptions' do + expect(app).to receive(:call).with(env).and_raise(RuntimeError) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:add_event).with(:rails_exception) + + expect { middleware.call(env) }.to raise_error(RuntimeError) + end end describe '#transaction_from_env' do diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb index 4d2aa03e722..acaba785606 100644 --- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb @@ -12,7 +12,9 @@ describe Gitlab::Metrics::SidekiqMiddleware do with('TestWorker#perform'). and_call_original - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set). + with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) middleware.call(worker, message, :test) { nil } @@ -25,10 +27,28 @@ describe Gitlab::Metrics::SidekiqMiddleware do with('TestWorker#perform'). and_call_original - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set). + with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) middleware.call(worker, {}, :test) { nil } end + + it 'tracks any raised exceptions' do + worker = double(:worker, class: double(:class, name: 'TestWorker')) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:run).and_raise(RuntimeError) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:add_event).with(:sidekiq_exception) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:finish) + + expect { middleware.call(worker, message, :test) }. + to raise_error(RuntimeError) + end end end diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb index f1a191d9410..3887c04c832 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/transaction_spec.rb @@ -142,5 +142,62 @@ describe Gitlab::Metrics::Transaction do transaction.submit end + + it 'does not add an action tag for events' do + transaction.action = 'Foo#bar' + transaction.add_event(:meow) + + hash = { + series: 'events', + tags: { event: :meow }, + values: { count: 1 }, + timestamp: an_instance_of(Fixnum) + } + + expect(Gitlab::Metrics).to receive(:submit_metrics). + with([hash]) + + transaction.submit + end + end + + describe '#add_event' do + it 'adds a metric' do + transaction.add_event(:meow) + + expect(transaction.metrics[0]).to be_an_instance_of(Gitlab::Metrics::Metric) + end + + it "does not prefix the metric's series name" do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.series).to eq(described_class::EVENT_SERIES) + end + + it 'tracks a counter for every event' do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.values).to eq(count: 1) + end + + it 'tracks the event name' do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.tags).to eq(event: :meow) + end + + it 'allows tracking of custom tags' do + transaction.add_event(:meow, animal: 'cat') + + metric = transaction.metrics[0] + + expect(metric.tags).to eq(event: :meow, animal: 'cat') + end end end diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index 84f9475a0f8..ab6e311b1e8 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -153,4 +153,28 @@ describe Gitlab::Metrics do expect(described_class.series_prefix).to be_an_instance_of(String) end end + + describe '.add_event' do + context 'without a transaction' do + it 'does nothing' do + expect_any_instance_of(Gitlab::Metrics::Transaction). + not_to receive(:add_event) + + Gitlab::Metrics.add_event(:meow) + end + end + + context 'with a transaction' do + it 'adds an event' do + transaction = Gitlab::Metrics::Transaction.new + + expect(transaction).to receive(:add_event).with(:meow) + + expect(Gitlab::Metrics).to receive(:current_transaction). + and_return(transaction) + + Gitlab::Metrics.add_event(:meow) + end + end + end end diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb new file mode 100644 index 00000000000..12d29540137 --- /dev/null +++ b/spec/models/board_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe Board do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + end +end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 2a09063f857..5a5d1a5d60c 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -5,8 +5,10 @@ describe Label, models: true do describe 'associations' do it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:label_links).dependent(:destroy) } it { is_expected.to have_many(:issues).through(:label_links).source(:target) } + it { is_expected.to have_many(:lists).dependent(:destroy) } end describe 'modules' do diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb new file mode 100644 index 00000000000..9e1a52011c3 --- /dev/null +++ b/spec/models/list_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +describe List do + describe 'relationships' do + it { is_expected.to belong_to(:board) } + it { is_expected.to belong_to(:label) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:board) } + it { is_expected.to validate_presence_of(:label) } + it { is_expected.to validate_presence_of(:list_type) } + it { is_expected.to validate_presence_of(:position) } + it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) } + + it 'validates uniqueness of label scoped to board_id' do + create(:list) + + expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id) + end + + context 'when list_type is set to backlog' do + subject { described_class.new(list_type: :backlog) } + + it { is_expected.not_to validate_presence_of(:label) } + it { is_expected.not_to validate_presence_of(:position) } + end + + context 'when list_type is set to done' do + subject { described_class.new(list_type: :done) } + + it { is_expected.not_to validate_presence_of(:label) } + it { is_expected.not_to validate_presence_of(:position) } + end + end + + describe '#destroy' do + it 'can be destroyed when when list_type is set to label' do + subject = create(:list) + + expect(subject.destroy).to be_truthy + end + + it 'can not be destroyed when list_type is set to backlog' do + subject = create(:backlog_list) + + expect(subject.destroy).to be_falsey + end + + it 'can not be destroyed when when list_type is set to done' do + subject = create(:done_list) + + expect(subject.destroy).to be_falsey + end + end + + describe '#destroyable?' do + it 'retruns true when list_type is set to label' do + subject.list_type = :label + + expect(subject).to be_destroyable + end + + it 'retruns false when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject).not_to be_destroyable + end + + it 'retruns false when list_type is set to done' do + subject.list_type = :done + + expect(subject).not_to be_destroyable + end + end + + describe '#movable?' do + it 'retruns true when list_type is set to label' do + subject.list_type = :label + + expect(subject).to be_movable + end + + it 'retruns false when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject).not_to be_movable + end + + it 'retruns false when list_type is set to done' do + subject.list_type = :done + + expect(subject).not_to be_movable + end + end + + describe '#title' do + it 'returns label name when list_type is set to label' do + subject.list_type = :label + subject.label = Label.new(name: 'Development') + + expect(subject.title).to eq 'Development' + end + + it 'returns Backlog when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject.title).to eq 'Backlog' + end + + it 'returns Done when list_type is set to done' do + subject.list_type = :done + + expect(subject.title).to eq 'Done' + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 00c528c8cde..64c56d922ff 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -456,6 +456,20 @@ describe MergeRequest, models: true do subject { create :merge_request, :simple } end + describe '#commits_sha' do + let(:commit0) { double('commit0', sha: 'sha1') } + let(:commit1) { double('commit1', sha: 'sha2') } + let(:commit2) { double('commit2', sha: 'sha3') } + + before do + allow(subject.merge_request_diff).to receive(:commits).and_return([commit0, commit1, commit2]) + end + + it 'returns sha of commits' do + expect(subject.commits_sha).to contain_exactly('sha1', 'sha2', 'sha3') + end + end + describe '#pipeline' do describe 'when the source project exists' do it 'returns the latest pipeline' do @@ -480,6 +494,19 @@ describe MergeRequest, models: true do end end + describe '#all_pipelines' do + let!(:pipelines) do + subject.merge_request_diff.commits.map do |commit| + create(:ci_empty_pipeline, project: subject.source_project, sha: commit.id, ref: subject.source_branch) + end + end + + it 'returns a pipelines from source projects with proper ordering' do + expect(subject.all_pipelines).not_to be_empty + expect(subject.all_pipelines).to eq(pipelines.reverse) + end + end + describe '#participants' do let(:project) { create(:project, :public) } @@ -848,4 +875,56 @@ describe MergeRequest, models: true do end end end + + describe '#conflicts_can_be_resolved_in_ui?' do + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| + mr.mark_as_unmergeable + end + end + + it 'returns a falsey value when the MR can be merged without conflicts' do + merge_request = create_merge_request('master') + merge_request.mark_as_mergeable + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR does not support new diff notes' do + merge_request = create_merge_request('conflict-resolvable') + merge_request.merge_request_diff.update_attributes(start_commit_sha: nil) + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a large file' do + merge_request = create_merge_request('conflict-too-large') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a binary file' do + merge_request = create_merge_request('conflict-binary-file') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do + merge_request = create_merge_request('conflict-contains-conflict-markers') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do + merge_request = create_merge_request('conflict-missing-side') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a truthy value when the conflicts are resolvable in the UI' do + merge_request = create_merge_request('conflict-resolvable') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 0a32a486703..d1f3a815290 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -23,6 +23,7 @@ describe Project, models: true do it { is_expected.to have_one(:slack_service).dependent(:destroy) } it { is_expected.to have_one(:pushover_service).dependent(:destroy) } it { is_expected.to have_one(:asana_service).dependent(:destroy) } + it { is_expected.to have_one(:board).dependent(:destroy) } it { is_expected.to have_many(:commit_statuses) } it { is_expected.to have_many(:pipelines) } it { is_expected.to have_many(:builds) } diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb new file mode 100644 index 00000000000..a1a4dd4c57c --- /dev/null +++ b/spec/services/boards/create_service_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Boards::CreateService, services: true do + describe '#execute' do + subject(:service) { described_class.new(project, double) } + + context 'when project does not have a board' do + let(:project) { create(:empty_project, board: nil) } + + it 'creates a new board' do + expect { service.execute }.to change(Board, :count).by(1) + end + + it 'creates default lists' do + service.execute + + expect(project.board.lists.size).to eq 2 + expect(project.board.lists.first).to be_backlog + expect(project.board.lists.last).to be_done + end + end + + context 'when project has a board' do + let!(:project) { create(:project_with_board) } + + it 'does not create a new board' do + expect { service.execute }.not_to change(Board, :count) + end + + it 'does not create board lists' do + expect { service.execute }.not_to change(project.board.lists, :count) + end + end + end +end diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb new file mode 100644 index 00000000000..f7f45983d26 --- /dev/null +++ b/spec/services/boards/issues/list_service_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe Boards::Issues::ListService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project_with_board) } + let(:board) { project.board } + + let(:bug) { create(:label, project: project, name: 'Bug') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } + let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } + let(:p3) { create(:label, title: 'P3', project: project, priority: 3) } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:list1) { create(:list, board: board, label: development, position: 0) } + let!(:list2) { create(:list, board: board, label: testing, position: 1) } + let!(:done) { create(:done_list, board: board) } + + let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) } + let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) } + let!(:reopened_issue1) { create(:issue, :reopened, project: project) } + + let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) } + let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) } + let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) } + let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) } + + let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) } + let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) } + let!(:closed_issue3) { create(:issue, :closed, project: project) } + let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) } + + before do + project.team << [user, :developer] + end + + it 'delegates search to IssuesFinder' do + params = { id: list1.id } + + expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original + + described_class.new(project, user, params).execute + end + + context 'sets default order to priority' do + it 'returns opened issues when listing issues from Backlog' do + params = { id: backlog.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1] + end + + it 'returns closed issues when listing issues from Done' do + params = { id: done.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1] + end + + it 'returns opened issues that have label list applied when listing issues from a label list' do + params = { id: list1.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2] + end + end + end +end diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb new file mode 100644 index 00000000000..0122159cab8 --- /dev/null +++ b/spec/services/boards/issues/move_service_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +describe Boards::Issues::MoveService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project_with_board) } + let(:board) { project.board } + + let(:bug) { create(:label, project: project, name: 'Bug') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:list1) { create(:list, board: board, label: development, position: 0) } + let!(:list2) { create(:list, board: board, label: testing, position: 1) } + let!(:done) { create(:done_list, board: board) } + + before do + project.team << [user, :developer] + end + + context 'when moving from backlog' do + it 'adds the label of the list it goes to' do + issue = create(:labeled_issue, project: project, labels: [bug]) + params = { from_list_id: backlog.id, to_list_id: list1.id } + + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, development) + end + end + + context 'when moving to backlog' do + it 'removes all list-labels' do + issue = create(:labeled_issue, project: project, labels: [bug, development, testing]) + params = { from_list_id: list1.id, to_list_id: backlog.id } + + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug) + end + end + + context 'when moving from backlog to done' do + it 'closes the issue' do + issue = create(:labeled_issue, project: project, labels: [bug]) + params = { from_list_id: backlog.id, to_list_id: done.id } + + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_closed + end + end + + context 'when moving an issue between lists' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { from_list_id: list1.id, to_list_id: list2.id } } + + it 'delegates the label changes to Issues::UpdateService' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'removes the label from the list it came from and adds the label of the list it goes to' do + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, testing) + end + end + + context 'when moving to done' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing]) } + let(:params) { { from_list_id: list2.id, to_list_id: done.id } } + + it 'delegates the close proceedings to Issues::CloseService' do + expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'removes all list-labels and close the issue' do + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_closed + end + end + + context 'when moving from done' do + let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) } + let(:params) { { from_list_id: done.id, to_list_id: list2.id } } + + it 'delegates the re-open proceedings to Issues::ReopenService' do + expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'adds the label of the list it goes to and reopen the issue' do + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug, testing) + expect(issue).to be_reopened + end + end + + context 'when moving from done to backlog' do + it 'reopens the issue' do + issue = create(:labeled_issue, :closed, project: project, labels: [bug]) + params = { from_list_id: done.id, to_list_id: backlog.id } + + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_reopened + end + end + + context 'when moving to same list' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { from_list_id: list1.id, to_list_id: list1.id } } + + it 'returns false' do + expect(described_class.new(project, user, params).execute(issue)).to eq false + end + + it 'keeps issues labels' do + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, development) + end + end + end +end diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb new file mode 100644 index 00000000000..5e7e145065e --- /dev/null +++ b/spec/services/boards/lists/create_service_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Boards::Lists::CreateService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + let(:label) { create(:label, name: 'in-progress') } + + subject(:service) { described_class.new(project, user, label_id: label.id) } + + context 'when board lists is empty' do + it 'creates a new list at beginning of the list' do + list = service.execute + + expect(list.position).to eq 0 + end + end + + context 'when board lists has only a backlog list' do + it 'creates a new list at beginning of the list' do + create(:backlog_list, board: board) + + list = service.execute + + expect(list.position).to eq 0 + end + end + + context 'when board lists has only labels lists' do + it 'creates a new list at end of the lists' do + create(:list, board: board, position: 0) + create(:list, board: board, position: 1) + + list = service.execute + + expect(list.position).to eq 2 + end + end + + context 'when board lists has backlog, label and done lists' do + it 'creates a new list at end of the label lists' do + create(:backlog_list, board: board) + create(:done_list, board: board) + list1 = create(:list, board: board, position: 0) + + list2 = service.execute + + expect(list1.reload.position).to eq 0 + expect(list2.reload.position).to eq 1 + end + end + end +end diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb new file mode 100644 index 00000000000..6eff445feee --- /dev/null +++ b/spec/services/boards/lists/destroy_service_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Boards::Lists::DestroyService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + + context 'when list type is label' do + it 'removes list from board' do + list = create(:list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.to change(board.lists, :count).by(-1) + end + + it 'decrements position of higher lists' do + backlog = create(:backlog_list, board: board) + development = create(:list, board: board, position: 0) + review = create(:list, board: board, position: 1) + staging = create(:list, board: board, position: 2) + done = create(:done_list, board: board) + + described_class.new(project, user).execute(development) + + expect(backlog.reload.position).to be_nil + expect(review.reload.position).to eq 0 + expect(staging.reload.position).to eq 1 + expect(done.reload.position).to be_nil + end + end + + it 'does not remove list from board when list type is backlog' do + list = create(:backlog_list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.not_to change(board.lists, :count) + end + + it 'does not remove list from board when list type is done' do + list = create(:done_list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.not_to change(board.lists, :count) + end + end +end diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb new file mode 100644 index 00000000000..9fd39122737 --- /dev/null +++ b/spec/services/boards/lists/generate_service_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Boards::Lists::GenerateService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + + subject(:service) { described_class.new(project, user) } + + context 'when board lists is empty' do + it 'creates the default lists' do + expect { service.execute }.to change(board.lists, :count).by(4) + end + end + + context 'when board lists is not empty' do + it 'does not creates the default lists' do + create(:list, board: board) + + expect { service.execute }.not_to change(board.lists, :count) + end + end + + context 'when project labels does not contains any list label' do + it 'creates labels' do + expect { service.execute }.to change(project.labels, :count).by(4) + end + end + + context 'when project labels contains some of list label' do + it 'creates the missing labels' do + create(:label, project: project, name: 'Development') + create(:label, project: project, name: 'Ready') + + expect { service.execute }.to change(project.labels, :count).by(2) + end + end + end +end diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb new file mode 100644 index 00000000000..3e9b7d07fc6 --- /dev/null +++ b/spec/services/boards/lists/move_service_spec.rb @@ -0,0 +1,110 @@ +require 'spec_helper' + +describe Boards::Lists::MoveService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:planning) { create(:list, board: board, position: 0) } + let!(:development) { create(:list, board: board, position: 1) } + let!(:review) { create(:list, board: board, position: 2) } + let!(:staging) { create(:list, board: board, position: 3) } + let!(:done) { create(:done_list, board: board) } + + context 'when list type is set to label' do + it 'keeps position of lists when new position is nil' do + service = described_class.new(project, user, position: nil) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is equal to old position' do + service = described_class.new(project, user, position: planning.position) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is negative' do + service = described_class.new(project, user, position: -1) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is equal to number of labels lists' do + service = described_class.new(project, user, position: board.lists.label.size) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is greater than number of labels lists' do + service = described_class.new(project, user, position: board.lists.label.size + 1) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'increments position of intermediate lists when new positon is equal to first position' do + service = described_class.new(project, user, position: 0) + + service.execute(staging) + + expect(current_list_positions).to eq [1, 2, 3, 0] + end + + it 'decrements position of intermediate lists when new positon is equal to last position' do + service = described_class.new(project, user, position: board.lists.label.last.position) + + service.execute(planning) + + expect(current_list_positions).to eq [3, 0, 1, 2] + end + + it 'decrements position of intermediate lists when new position is greater than old position' do + service = described_class.new(project, user, position: 2) + + service.execute(planning) + + expect(current_list_positions).to eq [2, 0, 1, 3] + end + + it 'increments position of intermediate lists when new position is lower than old position' do + service = described_class.new(project, user, position: 1) + + service.execute(staging) + + expect(current_list_positions).to eq [0, 2, 3, 1] + end + end + + it 'keeps position of lists when list type is backlog' do + service = described_class.new(project, user, position: 2) + + service.execute(backlog) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when list type is done' do + service = described_class.new(project, user, position: 2) + + service.execute(done) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + end + + def current_list_positions + [planning, development, review, staging].map { |list| list.reload.position } + end +end diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb new file mode 100644 index 00000000000..e42d727672b --- /dev/null +++ b/spec/support/api/schema_matcher.rb @@ -0,0 +1,8 @@ +RSpec::Matchers.define :match_response_schema do |schema, **options| + match do |response| + schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas" + schema_path = "#{schema_directory}/#{schema}.json" + + JSON::Validator.validate!(schema_path, response.body, options) + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1c0c66969e3..edbbfc3c9e5 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -5,25 +5,31 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { - 'empty-branch' => '7efb185', - 'ends-with.json' => '98b0d8b3', - 'flatten-dir' => 'e56497b', - 'feature' => '0b4bc9a', - 'feature_conflict' => 'bb5206f', - 'fix' => '48f0be4', - 'improve/awesome' => '5937ac0', - 'markdown' => '0ed8c6c', - 'lfs' => 'be93687', - 'master' => '5937ac0', - "'test'" => 'e56497b', - 'orphaned-branch' => '45127a9', - 'binary-encoding' => '7b1cf43', - 'gitattributes' => '5a62481', - 'expand-collapse-diffs' => '4842455', - 'expand-collapse-files' => '025db92', - 'expand-collapse-lines' => '238e82d', - 'video' => '8879059', - 'crlf-diff' => '5938907' + 'empty-branch' => '7efb185', + 'ends-with.json' => '98b0d8b3', + 'flatten-dir' => 'e56497b', + 'feature' => '0b4bc9a', + 'feature_conflict' => 'bb5206f', + 'fix' => '48f0be4', + 'improve/awesome' => '5937ac0', + 'markdown' => '0ed8c6c', + 'lfs' => 'be93687', + 'master' => '5937ac0', + "'test'" => 'e56497b', + 'orphaned-branch' => '45127a9', + 'binary-encoding' => '7b1cf43', + 'gitattributes' => '5a62481', + 'expand-collapse-diffs' => '4842455', + 'expand-collapse-files' => '025db92', + 'expand-collapse-lines' => '238e82d', + 'video' => '8879059', + 'crlf-diff' => '5938907', + 'conflict-start' => '14fa46b', + 'conflict-resolvable' => '1450cd6', + 'conflict-binary-file' => '259a6fb', + 'conflict-contains-conflict-markers' => '5e0964c', + 'conflict-missing-side' => 'eb227b3', + 'conflict-too-large' => '39fa04f', } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js new file mode 100644 index 00000000000..eca7c5012b2 --- /dev/null +++ b/vendor/assets/javascripts/Sortable.js @@ -0,0 +1,1285 @@ +/**! + * Sortable + * @author RubaXa <trash@rubaxa.org> + * @license MIT + */ + + +(function (factory) { + "use strict"; + + if (typeof define === "function" && define.amd) { + define(factory); + } + else if (typeof module != "undefined" && typeof module.exports != "undefined") { + module.exports = factory(); + } + else if (typeof Package !== "undefined") { + Sortable = factory(); // export for Meteor.js + } + else { + /* jshint sub:true */ + window["Sortable"] = factory(); + } +})(function () { + "use strict"; + + var dragEl, + parentEl, + ghostEl, + cloneEl, + rootEl, + nextEl, + + scrollEl, + scrollParentEl, + + lastEl, + lastCSS, + lastParentCSS, + + oldIndex, + newIndex, + + activeGroup, + autoScroll = {}, + + tapEvt, + touchEvt, + + moved, + + /** @const */ + RSPACE = /\s+/g, + + expando = 'Sortable' + (new Date).getTime(), + + win = window, + document = win.document, + parseInt = win.parseInt, + + supportDraggable = !!('draggable' in document.createElement('div')), + supportCssPointerEvents = (function (el) { + el = document.createElement('x'); + el.style.cssText = 'pointer-events:auto'; + return el.style.pointerEvents === 'auto'; + })(), + + _silent = false, + + abs = Math.abs, + min = Math.min, + slice = [].slice, + + touchDragOverListeners = [], + + _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) { + // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521 + if (rootEl && options.scroll) { + var el, + rect, + sens = options.scrollSensitivity, + speed = options.scrollSpeed, + + x = evt.clientX, + y = evt.clientY, + + winWidth = window.innerWidth, + winHeight = window.innerHeight, + + vx, + vy + ; + + // Delect scrollEl + if (scrollParentEl !== rootEl) { + scrollEl = options.scroll; + scrollParentEl = rootEl; + + if (scrollEl === true) { + scrollEl = rootEl; + + do { + if ((scrollEl.offsetWidth < scrollEl.scrollWidth) || + (scrollEl.offsetHeight < scrollEl.scrollHeight) + ) { + break; + } + /* jshint boss:true */ + } while (scrollEl = scrollEl.parentNode); + } + } + + if (scrollEl) { + el = scrollEl; + rect = scrollEl.getBoundingClientRect(); + vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens); + vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens); + } + + + if (!(vx || vy)) { + vx = (winWidth - x <= sens) - (x <= sens); + vy = (winHeight - y <= sens) - (y <= sens); + + /* jshint expr:true */ + (vx || vy) && (el = win); + } + + + if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) { + autoScroll.el = el; + autoScroll.vx = vx; + autoScroll.vy = vy; + + clearInterval(autoScroll.pid); + + if (el) { + autoScroll.pid = setInterval(function () { + if (el === win) { + win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed); + } else { + vy && (el.scrollTop += vy * speed); + vx && (el.scrollLeft += vx * speed); + } + }, 24); + } + } + } + }, 30), + + _prepareGroup = function (options) { + var group = options.group; + + if (!group || typeof group != 'object') { + group = options.group = {name: group}; + } + + ['pull', 'put'].forEach(function (key) { + if (!(key in group)) { + group[key] = true; + } + }); + + options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' '; + } + ; + + + + /** + * @class Sortable + * @param {HTMLElement} el + * @param {Object} [options] + */ + function Sortable(el, options) { + if (!(el && el.nodeType && el.nodeType === 1)) { + throw 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el); + } + + this.el = el; // root element + this.options = options = _extend({}, options); + + + // Export instance + el[expando] = this; + + + // Default options + var defaults = { + group: Math.random(), + sort: true, + disabled: false, + store: null, + handle: null, + scroll: true, + scrollSensitivity: 30, + scrollSpeed: 10, + draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*', + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + ignore: 'a, img', + filter: null, + animation: 0, + setData: function (dataTransfer, dragEl) { + dataTransfer.setData('Text', dragEl.textContent); + }, + dropBubble: false, + dragoverBubble: false, + dataIdAttr: 'data-id', + delay: 0, + forceFallback: false, + fallbackClass: 'sortable-fallback', + fallbackOnBody: false, + fallbackTolerance: 0 + }; + + + // Set default options + for (var name in defaults) { + !(name in options) && (options[name] = defaults[name]); + } + + _prepareGroup(options); + + // Bind all private methods + for (var fn in this) { + if (fn.charAt(0) === '_') { + this[fn] = this[fn].bind(this); + } + } + + // Setup drag mode + this.nativeDraggable = options.forceFallback ? false : supportDraggable; + + // Bind events + _on(el, 'mousedown', this._onTapStart); + _on(el, 'touchstart', this._onTapStart); + + if (this.nativeDraggable) { + _on(el, 'dragover', this); + _on(el, 'dragenter', this); + } + + touchDragOverListeners.push(this._onDragOver); + + // Restore sorting + options.store && this.sort(options.store.get(this)); + } + + + Sortable.prototype = /** @lends Sortable.prototype */ { + constructor: Sortable, + + _onTapStart: function (/** Event|TouchEvent */evt) { + var _this = this, + el = this.el, + options = this.options, + type = evt.type, + touch = evt.touches && evt.touches[0], + target = (touch || evt).target, + originalTarget = target, + filter = options.filter, + startIndex; + + // Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group. + if (dragEl) { + return; + } + + if (type === 'mousedown' && evt.button !== 0 || options.disabled) { + return; // only left button or enabled + } + + target = _closest(target, options.draggable, el); + + if (!target) { + return; + } + + if (options.handle && !_closest(originalTarget, options.handle, el)) { + return; + } + + // Get the index of the dragged element within its parent + startIndex = _index(target, options.draggable); + + // Check filter + if (typeof filter === 'function') { + if (filter.call(this, evt, target, this)) { + _dispatchEvent(_this, originalTarget, 'filter', target, el, startIndex); + evt.preventDefault(); + return; // cancel dnd + } + } + else if (filter) { + filter = filter.split(',').some(function (criteria) { + criteria = _closest(originalTarget, criteria.trim(), el); + + if (criteria) { + _dispatchEvent(_this, criteria, 'filter', target, el, startIndex); + return true; + } + }); + + if (filter) { + evt.preventDefault(); + return; // cancel dnd + } + } + + // Prepare `dragstart` + this._prepareDragStart(evt, touch, target, startIndex); + }, + + _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) { + var _this = this, + el = _this.el, + options = _this.options, + ownerDocument = el.ownerDocument, + dragStartFn; + + if (target && !dragEl && (target.parentNode === el)) { + tapEvt = evt; + + rootEl = el; + dragEl = target; + parentEl = dragEl.parentNode; + nextEl = dragEl.nextSibling; + activeGroup = options.group; + oldIndex = startIndex; + + this._lastX = (touch || evt).clientX; + this._lastY = (touch || evt).clientY; + + dragStartFn = function () { + // Delayed drag has been triggered + // we can re-enable the events: touchmove/mousemove + _this._disableDelayedDrag(); + + // Make the element draggable + dragEl.draggable = true; + + // Chosen item + _toggleClass(dragEl, _this.options.chosenClass, true); + + // Bind the events: dragstart/dragend + _this._triggerDragStart(touch); + + // Drag start event + _dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, oldIndex); + }; + + // Disable "draggable" + options.ignore.split(',').forEach(function (criteria) { + _find(dragEl, criteria.trim(), _disableDraggable); + }); + + _on(ownerDocument, 'mouseup', _this._onDrop); + _on(ownerDocument, 'touchend', _this._onDrop); + _on(ownerDocument, 'touchcancel', _this._onDrop); + + if (options.delay) { + // If the user moves the pointer or let go the click or touch + // before the delay has been reached: + // disable the delayed drag + _on(ownerDocument, 'mouseup', _this._disableDelayedDrag); + _on(ownerDocument, 'touchend', _this._disableDelayedDrag); + _on(ownerDocument, 'touchcancel', _this._disableDelayedDrag); + _on(ownerDocument, 'mousemove', _this._disableDelayedDrag); + _on(ownerDocument, 'touchmove', _this._disableDelayedDrag); + + _this._dragStartTimer = setTimeout(dragStartFn, options.delay); + } else { + dragStartFn(); + } + } + }, + + _disableDelayedDrag: function () { + var ownerDocument = this.el.ownerDocument; + + clearTimeout(this._dragStartTimer); + _off(ownerDocument, 'mouseup', this._disableDelayedDrag); + _off(ownerDocument, 'touchend', this._disableDelayedDrag); + _off(ownerDocument, 'touchcancel', this._disableDelayedDrag); + _off(ownerDocument, 'mousemove', this._disableDelayedDrag); + _off(ownerDocument, 'touchmove', this._disableDelayedDrag); + }, + + _triggerDragStart: function (/** Touch */touch) { + if (touch) { + // Touch device support + tapEvt = { + target: dragEl, + clientX: touch.clientX, + clientY: touch.clientY + }; + + this._onDragStart(tapEvt, 'touch'); + } + else if (!this.nativeDraggable) { + this._onDragStart(tapEvt, true); + } + else { + _on(dragEl, 'dragend', this); + _on(rootEl, 'dragstart', this._onDragStart); + } + + try { + if (document.selection) { + document.selection.empty(); + } else { + window.getSelection().removeAllRanges(); + } + } catch (err) { + } + }, + + _dragStarted: function () { + if (rootEl && dragEl) { + // Apply effect + _toggleClass(dragEl, this.options.ghostClass, true); + + Sortable.active = this; + + // Drag start event + _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex); + } + }, + + _emulateDragOver: function () { + if (touchEvt) { + if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) { + return; + } + + this._lastX = touchEvt.clientX; + this._lastY = touchEvt.clientY; + + if (!supportCssPointerEvents) { + _css(ghostEl, 'display', 'none'); + } + + var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY), + parent = target, + groupName = ' ' + this.options.group.name + '', + i = touchDragOverListeners.length; + + if (parent) { + do { + if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) { + while (i--) { + touchDragOverListeners[i]({ + clientX: touchEvt.clientX, + clientY: touchEvt.clientY, + target: target, + rootEl: parent + }); + } + + break; + } + + target = parent; // store last element + } + /* jshint boss:true */ + while (parent = parent.parentNode); + } + + if (!supportCssPointerEvents) { + _css(ghostEl, 'display', ''); + } + } + }, + + + _onTouchMove: function (/**TouchEvent*/evt) { + if (tapEvt) { + var options = this.options, + fallbackTolerance = options.fallbackTolerance, + touch = evt.touches ? evt.touches[0] : evt, + dx = touch.clientX - tapEvt.clientX, + dy = touch.clientY - tapEvt.clientY, + translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)'; + + // only set the status to dragging, when we are actually dragging + if (!Sortable.active) { + if (fallbackTolerance && + min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance + ) { + return; + } + + this._dragStarted(); + } + + // as well as creating the ghost element on the document body + this._appendGhost(); + + moved = true; + touchEvt = touch; + + _css(ghostEl, 'webkitTransform', translate3d); + _css(ghostEl, 'mozTransform', translate3d); + _css(ghostEl, 'msTransform', translate3d); + _css(ghostEl, 'transform', translate3d); + + evt.preventDefault(); + } + }, + + _appendGhost: function () { + if (!ghostEl) { + var rect = dragEl.getBoundingClientRect(), + css = _css(dragEl), + options = this.options, + ghostRect; + + ghostEl = dragEl.cloneNode(true); + + _toggleClass(ghostEl, options.ghostClass, false); + _toggleClass(ghostEl, options.fallbackClass, true); + + _css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10)); + _css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10)); + _css(ghostEl, 'width', rect.width); + _css(ghostEl, 'height', rect.height); + _css(ghostEl, 'opacity', '0.8'); + _css(ghostEl, 'position', 'fixed'); + _css(ghostEl, 'zIndex', '100000'); + _css(ghostEl, 'pointerEvents', 'none'); + + options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl); + + // Fixing dimensions. + ghostRect = ghostEl.getBoundingClientRect(); + _css(ghostEl, 'width', rect.width * 2 - ghostRect.width); + _css(ghostEl, 'height', rect.height * 2 - ghostRect.height); + } + }, + + _onDragStart: function (/**Event*/evt, /**boolean*/useFallback) { + var dataTransfer = evt.dataTransfer, + options = this.options; + + this._offUpEvents(); + + if (activeGroup.pull == 'clone') { + cloneEl = dragEl.cloneNode(true); + _css(cloneEl, 'display', 'none'); + rootEl.insertBefore(cloneEl, dragEl); + _dispatchEvent(this, rootEl, 'clone', dragEl); + } + + if (useFallback) { + if (useFallback === 'touch') { + // Bind touch events + _on(document, 'touchmove', this._onTouchMove); + _on(document, 'touchend', this._onDrop); + _on(document, 'touchcancel', this._onDrop); + } else { + // Old brwoser + _on(document, 'mousemove', this._onTouchMove); + _on(document, 'mouseup', this._onDrop); + } + + this._loopId = setInterval(this._emulateDragOver, 50); + } + else { + if (dataTransfer) { + dataTransfer.effectAllowed = 'move'; + options.setData && options.setData.call(this, dataTransfer, dragEl); + } + + _on(document, 'drop', this); + setTimeout(this._dragStarted, 0); + } + }, + + _onDragOver: function (/**Event*/evt) { + var el = this.el, + target, + dragRect, + revert, + options = this.options, + group = options.group, + groupPut = group.put, + isOwner = (activeGroup === group), + canSort = options.sort; + + if (evt.preventDefault !== void 0) { + evt.preventDefault(); + !options.dragoverBubble && evt.stopPropagation(); + } + + moved = true; + + if (activeGroup && !options.disabled && + (isOwner + ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list + : activeGroup.pull && groupPut && ( + (activeGroup.name === group.name) || // by Name + (groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array + ) + ) && + (evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback + ) { + // Smart auto-scrolling + _autoScroll(evt, options, this.el); + + if (_silent) { + return; + } + + target = _closest(evt.target, options.draggable, el); + dragRect = dragEl.getBoundingClientRect(); + + if (revert) { + _cloneHide(true); + parentEl = rootEl; // actualization + + if (cloneEl || nextEl) { + rootEl.insertBefore(dragEl, cloneEl || nextEl); + } + else if (!canSort) { + rootEl.appendChild(dragEl); + } + + return; + } + + + if ((el.children.length === 0) || (el.children[0] === ghostEl) || + (el === evt.target) && (target = _ghostIsLast(el, evt)) + ) { + + if (target) { + if (target.animated) { + return; + } + + targetRect = target.getBoundingClientRect(); + } + + _cloneHide(isOwner); + + if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) { + if (!dragEl.contains(el)) { + el.appendChild(dragEl); + parentEl = el; // actualization + } + + this._animate(dragRect, dragEl); + target && this._animate(targetRect, target); + } + } + else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) { + if (lastEl !== target) { + lastEl = target; + lastCSS = _css(target); + lastParentCSS = _css(target.parentNode); + } + + + var targetRect = target.getBoundingClientRect(), + width = targetRect.right - targetRect.left, + height = targetRect.bottom - targetRect.top, + floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display) + || (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0), + isWide = (target.offsetWidth > dragEl.offsetWidth), + isLong = (target.offsetHeight > dragEl.offsetHeight), + halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5, + nextSibling = target.nextElementSibling, + moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect), + after + ; + + if (moveVector !== false) { + _silent = true; + setTimeout(_unsilent, 30); + + _cloneHide(isOwner); + + if (moveVector === 1 || moveVector === -1) { + after = (moveVector === 1); + } + else if (floating) { + var elTop = dragEl.offsetTop, + tgTop = target.offsetTop; + + if (elTop === tgTop) { + after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide; + } + else if (target.previousElementSibling === dragEl || dragEl.previousElementSibling === target) { + after = (evt.clientY - targetRect.top) / height > 0.5; + } else { + after = tgTop > elTop; + } + } else { + after = (nextSibling !== dragEl) && !isLong || halfway && isLong; + } + + if (!dragEl.contains(el)) { + if (after && !nextSibling) { + el.appendChild(dragEl); + } else { + target.parentNode.insertBefore(dragEl, after ? nextSibling : target); + } + } + + parentEl = dragEl.parentNode; // actualization + + this._animate(dragRect, dragEl); + this._animate(targetRect, target); + } + } + } + }, + + _animate: function (prevRect, target) { + var ms = this.options.animation; + + if (ms) { + var currentRect = target.getBoundingClientRect(); + + _css(target, 'transition', 'none'); + _css(target, 'transform', 'translate3d(' + + (prevRect.left - currentRect.left) + 'px,' + + (prevRect.top - currentRect.top) + 'px,0)' + ); + + target.offsetWidth; // repaint + + _css(target, 'transition', 'all ' + ms + 'ms'); + _css(target, 'transform', 'translate3d(0,0,0)'); + + clearTimeout(target.animated); + target.animated = setTimeout(function () { + _css(target, 'transition', ''); + _css(target, 'transform', ''); + target.animated = false; + }, ms); + } + }, + + _offUpEvents: function () { + var ownerDocument = this.el.ownerDocument; + + _off(document, 'touchmove', this._onTouchMove); + _off(ownerDocument, 'mouseup', this._onDrop); + _off(ownerDocument, 'touchend', this._onDrop); + _off(ownerDocument, 'touchcancel', this._onDrop); + }, + + _onDrop: function (/**Event*/evt) { + var el = this.el, + options = this.options; + + clearInterval(this._loopId); + clearInterval(autoScroll.pid); + clearTimeout(this._dragStartTimer); + + // Unbind events + _off(document, 'mousemove', this._onTouchMove); + + if (this.nativeDraggable) { + _off(document, 'drop', this); + _off(el, 'dragstart', this._onDragStart); + } + + this._offUpEvents(); + + if (evt) { + if (moved) { + evt.preventDefault(); + !options.dropBubble && evt.stopPropagation(); + } + + ghostEl && ghostEl.parentNode.removeChild(ghostEl); + + if (dragEl) { + if (this.nativeDraggable) { + _off(dragEl, 'dragend', this); + } + + _disableDraggable(dragEl); + + // Remove class's + _toggleClass(dragEl, this.options.ghostClass, false); + _toggleClass(dragEl, this.options.chosenClass, false); + + if (rootEl !== parentEl) { + newIndex = _index(dragEl, options.draggable); + + if (newIndex >= 0) { + // drag from one list and drop into another + _dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + + // Add event + _dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex); + + // Remove event + _dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex); + } + } + else { + // Remove clone + cloneEl && cloneEl.parentNode.removeChild(cloneEl); + + if (dragEl.nextSibling !== nextEl) { + // Get the index of the dragged element within its parent + newIndex = _index(dragEl, options.draggable); + + if (newIndex >= 0) { + // drag & drop within the same list + _dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex); + _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + } + } + } + + if (Sortable.active) { + if (newIndex === null || newIndex === -1) { + newIndex = oldIndex; + } + + _dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex); + + // Save sorting + this.save(); + } + } + + } + + this._nulling(); + }, + + _nulling: function () { + rootEl = + dragEl = + parentEl = + ghostEl = + nextEl = + cloneEl = + + scrollEl = + scrollParentEl = + + tapEvt = + touchEvt = + + moved = + newIndex = + + lastEl = + lastCSS = + + activeGroup = + Sortable.active = null; + }, + + handleEvent: function (/**Event*/evt) { + var type = evt.type; + + if (type === 'dragover' || type === 'dragenter') { + if (dragEl) { + this._onDragOver(evt); + _globalDragOver(evt); + } + } + else if (type === 'drop' || type === 'dragend') { + this._onDrop(evt); + } + }, + + + /** + * Serializes the item into an array of string. + * @returns {String[]} + */ + toArray: function () { + var order = [], + el, + children = this.el.children, + i = 0, + n = children.length, + options = this.options; + + for (; i < n; i++) { + el = children[i]; + if (_closest(el, options.draggable, this.el)) { + order.push(el.getAttribute(options.dataIdAttr) || _generateId(el)); + } + } + + return order; + }, + + + /** + * Sorts the elements according to the array. + * @param {String[]} order order of the items + */ + sort: function (order) { + var items = {}, rootEl = this.el; + + this.toArray().forEach(function (id, i) { + var el = rootEl.children[i]; + + if (_closest(el, this.options.draggable, rootEl)) { + items[id] = el; + } + }, this); + + order.forEach(function (id) { + if (items[id]) { + rootEl.removeChild(items[id]); + rootEl.appendChild(items[id]); + } + }); + }, + + + /** + * Save the current sorting + */ + save: function () { + var store = this.options.store; + store && store.set(this); + }, + + + /** + * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree. + * @param {HTMLElement} el + * @param {String} [selector] default: `options.draggable` + * @returns {HTMLElement|null} + */ + closest: function (el, selector) { + return _closest(el, selector || this.options.draggable, this.el); + }, + + + /** + * Set/get option + * @param {string} name + * @param {*} [value] + * @returns {*} + */ + option: function (name, value) { + var options = this.options; + + if (value === void 0) { + return options[name]; + } else { + options[name] = value; + + if (name === 'group') { + _prepareGroup(options); + } + } + }, + + + /** + * Destroy + */ + destroy: function () { + var el = this.el; + + el[expando] = null; + + _off(el, 'mousedown', this._onTapStart); + _off(el, 'touchstart', this._onTapStart); + + if (this.nativeDraggable) { + _off(el, 'dragover', this); + _off(el, 'dragenter', this); + } + + // Remove draggable attributes + Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) { + el.removeAttribute('draggable'); + }); + + touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1); + + this._onDrop(); + + this.el = el = null; + } + }; + + + function _cloneHide(state) { + if (cloneEl && (cloneEl.state !== state)) { + _css(cloneEl, 'display', state ? 'none' : ''); + !state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl); + cloneEl.state = state; + } + } + + + function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) { + if (el) { + ctx = ctx || document; + + do { + if ((selector === '>*' && el.parentNode === ctx) || _matches(el, selector)) { + return el; + } + } + while (el !== ctx && (el = el.parentNode)); + } + + return null; + } + + + function _globalDragOver(/**Event*/evt) { + if (evt.dataTransfer) { + evt.dataTransfer.dropEffect = 'move'; + } + evt.preventDefault(); + } + + + function _on(el, event, fn) { + el.addEventListener(event, fn, false); + } + + + function _off(el, event, fn) { + el.removeEventListener(event, fn, false); + } + + + function _toggleClass(el, name, state) { + if (el) { + if (el.classList) { + el.classList[state ? 'add' : 'remove'](name); + } + else { + var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' '); + el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' '); + } + } + } + + + function _css(el, prop, val) { + var style = el && el.style; + + if (style) { + if (val === void 0) { + if (document.defaultView && document.defaultView.getComputedStyle) { + val = document.defaultView.getComputedStyle(el, ''); + } + else if (el.currentStyle) { + val = el.currentStyle; + } + + return prop === void 0 ? val : val[prop]; + } + else { + if (!(prop in style)) { + prop = '-webkit-' + prop; + } + + style[prop] = val + (typeof val === 'string' ? '' : 'px'); + } + } + } + + + function _find(ctx, tagName, iterator) { + if (ctx) { + var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length; + + if (iterator) { + for (; i < n; i++) { + iterator(list[i], i); + } + } + + return list; + } + + return []; + } + + + + function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) { + var evt = document.createEvent('Event'), + options = (sortable || rootEl[expando]).options, + onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1); + + evt.initEvent(name, true, true); + + evt.to = rootEl; + evt.from = fromEl || rootEl; + evt.item = targetEl || rootEl; + evt.clone = cloneEl; + + evt.oldIndex = startIndex; + evt.newIndex = newIndex; + + rootEl.dispatchEvent(evt); + + if (options[onName]) { + options[onName].call(sortable, evt); + } + } + + + function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) { + var evt, + sortable = fromEl[expando], + onMoveFn = sortable.options.onMove, + retVal; + + evt = document.createEvent('Event'); + evt.initEvent('move', true, true); + + evt.to = toEl; + evt.from = fromEl; + evt.dragged = dragEl; + evt.draggedRect = dragRect; + evt.related = targetEl || toEl; + evt.relatedRect = targetRect || toEl.getBoundingClientRect(); + + fromEl.dispatchEvent(evt); + + if (onMoveFn) { + retVal = onMoveFn.call(sortable, evt); + } + + return retVal; + } + + + function _disableDraggable(el) { + el.draggable = false; + } + + + function _unsilent() { + _silent = false; + } + + + /** @returns {HTMLElement|false} */ + function _ghostIsLast(el, evt) { + var lastEl = el.lastElementChild, + rect = lastEl.getBoundingClientRect(); + + return ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta + } + + + /** + * Generate id + * @param {HTMLElement} el + * @returns {String} + * @private + */ + function _generateId(el) { + var str = el.tagName + el.className + el.src + el.href + el.textContent, + i = str.length, + sum = 0; + + while (i--) { + sum += str.charCodeAt(i); + } + + return sum.toString(36); + } + + /** + * Returns the index of an element within its parent for a selected set of + * elements + * @param {HTMLElement} el + * @param {selector} selector + * @return {number} + */ + function _index(el, selector) { + var index = 0; + + if (!el || !el.parentNode) { + return -1; + } + + while (el && (el = el.previousElementSibling)) { + if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && (selector === '>*' || _matches(el, selector))) { + index++; + } + } + + return index; + } + + function _matches(/**HTMLElement*/el, /**String*/selector) { + if (el) { + selector = selector.split('.'); + + var tag = selector.shift().toUpperCase(), + re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g'); + + return ( + (tag === '' || el.nodeName.toUpperCase() == tag) && + (!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length) + ); + } + + return false; + } + + function _throttle(callback, ms) { + var args, _this; + + return function () { + if (args === void 0) { + args = arguments; + _this = this; + + setTimeout(function () { + if (args.length === 1) { + callback.call(_this, args[0]); + } else { + callback.apply(_this, args); + } + + args = void 0; + }, ms); + } + }; + } + + function _extend(dst, src) { + if (dst && src) { + for (var key in src) { + if (src.hasOwnProperty(key)) { + dst[key] = src[key]; + } + } + } + + return dst; + } + + + // Export utils + Sortable.utils = { + on: _on, + off: _off, + css: _css, + find: _find, + is: function (el, selector) { + return !!_closest(el, selector, el); + }, + extend: _extend, + throttle: _throttle, + closest: _closest, + toggleClass: _toggleClass, + index: _index + }; + + + /** + * Create sortable instance + * @param {HTMLElement} el + * @param {Object} [options] + */ + Sortable.create = function (el, options) { + return new Sortable(el, options); + }; + + + // Export + Sortable.version = '1.4.2'; + return Sortable; +}); diff --git a/vendor/assets/javascripts/clipboard.js b/vendor/assets/javascripts/clipboard.js index 1b1f4f0bd63..39d7d2306f8 100644 --- a/vendor/assets/javascripts/clipboard.js +++ b/vendor/assets/javascripts/clipboard.js @@ -154,12 +154,12 @@ function E () { E.prototype = { on: function (name, callback, ctx) { var e = this.e || (this.e = {}); - + (e[name] || (e[name] = [])).push({ fn: callback, ctx: ctx }); - + return this; }, @@ -169,7 +169,7 @@ E.prototype = { self.off(name, fn); callback.apply(ctx, arguments); }; - + return this.on(name, fn, ctx); }, @@ -178,11 +178,11 @@ E.prototype = { var evtArr = ((this.e || (this.e = {}))[name] || []).slice(); var i = 0; var len = evtArr.length; - + for (i; i < len; i++) { evtArr[i].fn.apply(evtArr[i].ctx, data); } - + return this; }, @@ -190,21 +190,21 @@ E.prototype = { var e = this.e || (this.e = {}); var evts = e[name]; var liveEvents = []; - + if (evts && callback) { for (var i = 0, len = evts.length; i < len; i++) { if (evts[i].fn !== callback) liveEvents.push(evts[i]); } } - + // Remove event from queue to prevent memory leak // Suggested by https://github.com/lazd // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910 - (liveEvents.length) + (liveEvents.length) ? e[name] = liveEvents : delete e[name]; - + return this; } }; @@ -618,4 +618,4 @@ exports['default'] = Clipboard; module.exports = exports['default']; },{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7) -});
\ No newline at end of file +}); |