diff options
193 files changed, 5008 insertions, 3168 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df3458b47dd..4e6fcab3808 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-git-2.7-phantomjs-2.1-node-7.1" +image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.7-phantomjs-2.1-node-7.1" cache: key: "ruby-233" diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 34c2e097ba8..6bb21e6a3af 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -25,14 +25,20 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab environment info +<details> + (For installations with omnibus-gitlab package run and paste the output of: `sudo gitlab-rake gitlab:env:info`) (For installations from source run and paste the output of: `sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production`) +</details> + #### Results of GitLab application Check +<details> + (For installations with omnibus-gitlab package run and paste the output of: `sudo gitlab-rake gitlab:check SANITIZE=true`) @@ -41,6 +47,8 @@ logs, and code as it's very hard to read otherwise.) (we will only investigate if the tests are passing) +</details> + ### Possible fixes (If you can, link to the line of code that might be responsible for the problem) diff --git a/Gemfile.lock b/Gemfile.lock index d7e3f7343d0..965c888ca79 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -330,7 +330,7 @@ GEM grape-entity (0.6.0) activesupport multi_json (>= 1.3.2) - grpc (1.1.2) + grpc (1.2.2) google-protobuf (~> 3.1) googleauth (~> 0.5.1) haml (4.0.7) diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 93b8960da2e..239eeacf2d7 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -7,100 +7,98 @@ import boardBlankState from './board_blank_state'; require('./board_delete'); require('./board_list'); -(() => { - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.Board = Vue.extend({ - template: '#js-board-template', - components: { - boardList, - 'board-delete': gl.issueBoards.BoardDelete, - boardBlankState, - }, - props: { - list: Object, - disabled: Boolean, - issueLinkBase: String, - rootPath: String, - }, - data () { - return { - detailIssue: Store.detail, - filter: Store.filter, - }; - }, - watch: { - filter: { - handler() { - this.list.page = 1; - this.list.getIssues(true); - }, - deep: true, +gl.issueBoards.Board = Vue.extend({ + template: '#js-board-template', + components: { + boardList, + 'board-delete': gl.issueBoards.BoardDelete, + boardBlankState, + }, + props: { + list: Object, + disabled: Boolean, + issueLinkBase: String, + rootPath: String, + }, + data () { + return { + detailIssue: Store.detail, + filter: Store.filter, + }; + }, + watch: { + filter: { + handler() { + this.list.page = 1; + this.list.getIssues(true); }, - detailIssue: { - handler () { - if (!Object.keys(this.detailIssue.issue).length) return; + deep: true, + }, + detailIssue: { + handler () { + if (!Object.keys(this.detailIssue.issue).length) return; - const issue = this.list.findIssue(this.detailIssue.issue.id); + const issue = this.list.findIssue(this.detailIssue.issue.id); - if (issue) { - const offsetLeft = this.$el.offsetLeft; - const boardsList = document.querySelectorAll('.boards-list')[0]; - const left = boardsList.scrollLeft - offsetLeft; - let right = (offsetLeft + this.$el.offsetWidth); + if (issue) { + const offsetLeft = this.$el.offsetLeft; + const boardsList = document.querySelectorAll('.boards-list')[0]; + const left = boardsList.scrollLeft - offsetLeft; + let right = (offsetLeft + this.$el.offsetWidth); - if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { - // -290 here because width of boardsList is animating so therefore - // getting the width here is incorrect - // 290 is the width of the sidebar - right -= (boardsList.offsetWidth - 290); - } else { - right -= boardsList.offsetWidth; - } + if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { + // -290 here because width of boardsList is animating so therefore + // getting the width here is incorrect + // 290 is the width of the sidebar + right -= (boardsList.offsetWidth - 290); + } else { + right -= boardsList.offsetWidth; + } - if (right - boardsList.scrollLeft > 0) { - $(boardsList).animate({ - scrollLeft: right - }, this.sortableOptions.animation); - } else if (left > 0) { - $(boardsList).animate({ - scrollLeft: offsetLeft - }, this.sortableOptions.animation); - } + if (right - boardsList.scrollLeft > 0) { + $(boardsList).animate({ + scrollLeft: right + }, this.sortableOptions.animation); + } else if (left > 0) { + $(boardsList).animate({ + scrollLeft: offsetLeft + }, this.sortableOptions.animation); } - }, - deep: true - } - }, - methods: { - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - } - }, - mounted () { - this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ - disabled: this.disabled, - group: 'boards', - draggable: '.is-draggable', - handle: '.js-board-handle', - onEnd: (e) => { - gl.issueBoards.onEnd(); + } + }, + deep: true + } + }, + methods: { + showNewIssueForm() { + this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; + } + }, + mounted () { + this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ + disabled: this.disabled, + group: 'boards', + draggable: '.is-draggable', + handle: '.js-board-handle', + onEnd: (e) => { + gl.issueBoards.onEnd(); - if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = this.sortable.toArray(); - const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { + const order = this.sortable.toArray(); + const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); - this.$nextTick(() => { - Store.moveList(list, order); - }); - } + this.$nextTick(() => { + Store.moveList(list, order); + }); } - }); + } + }); - this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); - }, - }); -})(); + this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); + }, +}); diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js index af621cfd57f..8a1b177bba8 100644 --- a/app/assets/javascripts/boards/components/board_delete.js +++ b/app/assets/javascripts/boards/components/board_delete.js @@ -2,22 +2,20 @@ import Vue from 'vue'; -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.BoardDelete = Vue.extend({ - props: { - list: Object - }, - methods: { - deleteBoard () { - $(this.$el).tooltip('hide'); +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(); - } + if (confirm('Are you sure you want to delete this list?')) { + this.list.destroy(); } } - }); -})(); + } +}); diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 3c080008244..004bac09f59 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -8,66 +8,64 @@ import Vue from 'vue'; require('./sidebar/remove_issue'); -(() => { - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.BoardSidebar = Vue.extend({ - props: { - currentUser: Object - }, - data() { - return { - detail: Store.detail, - issue: {}, - list: {}, - }; - }, - computed: { - showSidebar () { - return Object.keys(this.issue).length; - } - }, - watch: { - detail: { - handler () { - if (this.issue.id !== this.detail.issue.id) { - $('.js-issue-board-sidebar', this.$el).each((i, el) => { - $(el).data('glDropdown').clearMenu(); - }); - } - - this.issue = this.detail.issue; - this.list = this.detail.list; - }, - deep: true - }, - issue () { - if (this.showSidebar) { - this.$nextTick(() => { - $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); - $('.right-sidebar').getNiceScroll().resize(); +gl.issueBoards.BoardSidebar = Vue.extend({ + props: { + currentUser: Object + }, + data() { + return { + detail: Store.detail, + issue: {}, + list: {}, + }; + }, + computed: { + showSidebar () { + return Object.keys(this.issue).length; + } + }, + watch: { + detail: { + handler () { + if (this.issue.id !== this.detail.issue.id) { + $('.js-issue-board-sidebar', this.$el).each((i, el) => { + $(el).data('glDropdown').clearMenu(); }); } - } + + this.issue = this.detail.issue; + this.list = this.detail.list; + }, + deep: true }, - methods: { - closeSidebar () { - this.detail.issue = {}; + issue () { + if (this.showSidebar) { + this.$nextTick(() => { + $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); + $('.right-sidebar').getNiceScroll().resize(); + }); } - }, - mounted () { - new IssuableContext(this.currentUser); - new MilestoneSelect(); - new gl.DueDateSelectors(); - new LabelsSelect(); - new Sidebar(); - gl.Subscription.bindAll('.subscription'); - }, - components: { - removeBtn: gl.issueBoards.RemoveIssueBtn, - }, - }); -})(); + } + }, + methods: { + closeSidebar () { + this.detail.issue = {}; + } + }, + mounted () { + new IssuableContext(this.currentUser); + new MilestoneSelect(); + new gl.DueDateSelectors(); + new LabelsSelect(); + new Sidebar(); + gl.Subscription.bindAll('.subscription'); + }, + components: { + removeBtn: gl.issueBoards.RemoveIssueBtn, + }, +}); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index e48d3344a2b..fc154ee7b8b 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -1,141 +1,139 @@ import Vue from 'vue'; import eventHub from '../eventhub'; -(() => { - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.IssueCardInner = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - list: { - type: Object, - required: false, - default: () => ({}), - }, - rootPath: { - type: String, - required: true, - }, - updateFilters: { - type: Boolean, - required: false, - default: false, - }, +gl.issueBoards.IssueCardInner = Vue.extend({ + props: { + issue: { + type: Object, + required: true, }, - computed: { - cardUrl() { - return `${this.issueLinkBase}/${this.issue.id}`; - }, - assigneeUrl() { - return `${this.rootPath}${this.issue.assignee.username}`; - }, - assigneeUrlTitle() { - return `Assigned to ${this.issue.assignee.name}`; - }, - avatarUrlTitle() { - return `Avatar for ${this.issue.assignee.name}`; - }, - issueId() { - return `#${this.issue.id}`; - }, - showLabelFooter() { - return this.issue.labels.find(l => this.showLabel(l)) !== undefined; - }, + issueLinkBase: { + type: String, + required: true, }, - methods: { - showLabel(label) { - if (!this.list) return true; + list: { + type: Object, + required: false, + default: () => ({}), + }, + rootPath: { + type: String, + required: true, + }, + updateFilters: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; + }, + assigneeUrl() { + return `${this.rootPath}${this.issue.assignee.username}`; + }, + assigneeUrlTitle() { + return `Assigned to ${this.issue.assignee.name}`; + }, + avatarUrlTitle() { + return `Avatar for ${this.issue.assignee.name}`; + }, + issueId() { + return `#${this.issue.id}`; + }, + showLabelFooter() { + return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + }, + }, + methods: { + showLabel(label) { + if (!this.list) return true; - return !this.list.label || label.id !== this.list.label.id; - }, - filterByLabel(label, e) { - if (!this.updateFilters) return; + return !this.list.label || label.id !== this.list.label.id; + }, + filterByLabel(label, e) { + if (!this.updateFilters) return; - const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); - const labelTitle = encodeURIComponent(label.title); - const param = `label_name[]=${labelTitle}`; - const labelIndex = filterPath.indexOf(param); - $(e.currentTarget).tooltip('hide'); + const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); + const labelTitle = encodeURIComponent(label.title); + const param = `label_name[]=${labelTitle}`; + const labelIndex = filterPath.indexOf(param); + $(e.currentTarget).tooltip('hide'); - if (labelIndex === -1) { - filterPath.push(param); - } else { - filterPath.splice(labelIndex, 1); - } + if (labelIndex === -1) { + filterPath.push(param); + } else { + filterPath.splice(labelIndex, 1); + } - gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); + gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); - Store.updateFiltersUrl(); + Store.updateFiltersUrl(); - eventHub.$emit('updateTokens'); - }, - labelStyle(label) { - return { - backgroundColor: label.color, - color: label.textColor, - }; - }, + eventHub.$emit('updateTokens'); + }, + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.textColor, + }; }, - template: ` - <div> - <div class="card-header"> - <h4 class="card-title"> - <i - class="fa fa-eye-slash confidential-icon" - v-if="issue.confidential" - aria-hidden="true" - /> - <a - class="js-no-trigger" - :href="cardUrl" - :title="issue.title">{{ issue.title }}</a> - <span - class="card-number" - v-if="issue.id" - > - {{ issueId }} - </span> - </h4> + }, + template: ` + <div> + <div class="card-header"> + <h4 class="card-title"> + <i + class="fa fa-eye-slash confidential-icon" + v-if="issue.confidential" + aria-hidden="true" + /> <a - class="card-assignee has-tooltip js-no-trigger" - :href="assigneeUrl" - :title="assigneeUrlTitle" - v-if="issue.assignee" - data-container="body" + class="js-no-trigger" + :href="cardUrl" + :title="issue.title">{{ issue.title }}</a> + <span + class="card-number" + v-if="issue.id" > - <img - class="avatar avatar-inline s20 js-no-trigger" - :src="issue.assignee.avatar" - width="20" - height="20" - :alt="avatarUrlTitle" - /> - </a> - </div> - <div class="card-footer" v-if="showLabelFooter"> - <button - class="label color-label has-tooltip js-no-trigger" - v-for="label in issue.labels" - type="button" - v-if="showLabel(label)" - @click="filterByLabel(label, $event)" - :style="labelStyle(label)" - :title="label.description" - data-container="body"> - {{ label.title }} - </button> - </div> + {{ issueId }} + </span> + </h4> + <a + class="card-assignee has-tooltip js-no-trigger" + :href="assigneeUrl" + :title="assigneeUrlTitle" + v-if="issue.assignee" + data-container="body" + > + <img + class="avatar avatar-inline s20 js-no-trigger" + :src="issue.assignee.avatar" + width="20" + height="20" + :alt="avatarUrlTitle" + /> + </a> + </div> + <div class="card-footer" v-if="showLabelFooter"> + <button + class="label color-label has-tooltip js-no-trigger" + v-for="label in issue.labels" + type="button" + v-if="showLabel(label)" + @click="filterByLabel(label, $event)" + :style="labelStyle(label)" + :title="label.description" + data-container="body"> + {{ label.title }} + </button> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js index 823319df6e7..13569df0c20 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.js @@ -1,71 +1,69 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalEmptyState = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return ModalStore.store; +gl.issueBoards.ModalEmptyState = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + props: { + image: { + type: String, + required: true, }, - props: { - image: { - type: String, - required: true, - }, - newIssuePath: { - type: String, - required: true, - }, + newIssuePath: { + type: String, + required: true, }, - computed: { - contents() { - const obj = { - title: 'You haven\'t added any issues to your project yet', - content: ` - An issue can be a bug, a todo or a feature request that needs to be - discussed in a project. Besides, issues are searchable and filterable. - `, - }; + }, + computed: { + contents() { + const obj = { + title: 'You haven\'t added any issues to your project yet', + content: ` + An issue can be a bug, a todo or a feature request that needs to be + discussed in a project. Besides, issues are searchable and filterable. + `, + }; - if (this.activeTab === 'selected') { - obj.title = 'You haven\'t selected any issues yet'; - obj.content = ` - Go back to <strong>Open issues</strong> and select some issues - to add to your board. - `; - } + if (this.activeTab === 'selected') { + obj.title = 'You haven\'t selected any issues yet'; + obj.content = ` + Go back to <strong>Open issues</strong> and select some issues + to add to your board. + `; + } - return obj; - }, + return obj; }, - template: ` - <section class="empty-state"> - <div class="row"> - <div class="col-xs-12 col-sm-6 col-sm-push-6"> - <aside class="svg-content" v-html="image"></aside> - </div> - <div class="col-xs-12 col-sm-6 col-sm-pull-6"> - <div class="text-content"> - <h4>{{ contents.title }}</h4> - <p v-html="contents.content"></p> - <a - :href="newIssuePath" - class="btn btn-success btn-inverted" - v-if="activeTab === 'all'"> - New issue - </a> - <button - type="button" - class="btn btn-default" - @click="changeTab('all')" - v-if="activeTab === 'selected'"> - Open issues - </button> - </div> + }, + template: ` + <section class="empty-state"> + <div class="row"> + <div class="col-xs-12 col-sm-6 col-sm-push-6"> + <aside class="svg-content" v-html="image"></aside> + </div> + <div class="col-xs-12 col-sm-6 col-sm-pull-6"> + <div class="text-content"> + <h4>{{ contents.title }}</h4> + <p v-html="contents.content"></p> + <a + :href="newIssuePath" + class="btn btn-success btn-inverted" + v-if="activeTab === 'all'"> + New issue + </a> + <button + type="button" + class="btn btn-default" + @click="changeTab('all')" + v-if="activeTab === 'selected'"> + Open issues + </button> </div> </div> - </section> - `, - }); -})(); + </div> + </section> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index 887ce373096..ccd270b27da 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -5,80 +5,78 @@ import Vue from 'vue'; require('./lists_dropdown'); -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalFooter = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; +gl.issueBoards.ModalFooter = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + submitDisabled() { + return !ModalStore.selectedCount(); }, - computed: { - submitDisabled() { - return !ModalStore.selectedCount(); - }, - submitText() { - const count = ModalStore.selectedCount(); + submitText() { + const count = ModalStore.selectedCount(); - return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; - }, + return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; }, - methods: { - addIssues() { - const list = this.modal.selectedList || this.state.lists[0]; - const selectedIssues = ModalStore.getSelectedIssues(); - const issueIds = selectedIssues.map(issue => issue.globalId); + }, + methods: { + addIssues() { + const list = this.modal.selectedList || this.state.lists[0]; + const selectedIssues = ModalStore.getSelectedIssues(); + const issueIds = selectedIssues.map(issue => issue.globalId); - // Post the data to the backend - gl.boardService.bulkUpdate(issueIds, { - add_label_ids: [list.label.id], - }).catch(() => { - new Flash('Failed to update issues, please try again.', 'alert'); + // Post the data to the backend + gl.boardService.bulkUpdate(issueIds, { + add_label_ids: [list.label.id], + }).catch(() => { + new Flash('Failed to update issues, please try again.', 'alert'); - selectedIssues.forEach((issue) => { - list.removeIssue(issue); - list.issuesSize -= 1; - }); - }); - - // Add the issues on the frontend selectedIssues.forEach((issue) => { - list.addIssue(issue); - list.issuesSize += 1; + list.removeIssue(issue); + list.issuesSize -= 1; }); + }); - this.toggleModal(false); - }, - }, - components: { - 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + // Add the issues on the frontend + selectedIssues.forEach((issue) => { + list.addIssue(issue); + list.issuesSize += 1; + }); + + this.toggleModal(false); }, - template: ` - <footer - class="form-actions add-issues-footer"> - <div class="pull-left"> - <button - class="btn btn-success" - type="button" - :disabled="submitDisabled" - @click="addIssues"> - {{ submitText }} - </button> - <span class="inline add-issues-footer-to-list"> - to list - </span> - <lists-dropdown></lists-dropdown> - </div> + }, + components: { + 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + }, + template: ` + <footer + class="form-actions add-issues-footer"> + <div class="pull-left"> <button - class="btn btn-default pull-right" + class="btn btn-success" type="button" - @click="toggleModal(false)"> - Cancel + :disabled="submitDisabled" + @click="addIssues"> + {{ submitText }} </button> - </footer> - `, - }); -})(); + <span class="inline add-issues-footer-to-list"> + to list + </span> + <lists-dropdown></lists-dropdown> + </div> + <button + class="btn btn-default pull-right" + type="button" + @click="toggleModal(false)"> + Cancel + </button> + </footer> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js index 116e29cd177..e2b3f9ae7e2 100644 --- a/app/assets/javascripts/boards/components/modal/header.js +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -3,80 +3,78 @@ import modalFilters from './filters'; require('./tabs'); -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalHeader = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - props: { - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, +gl.issueBoards.ModalHeader = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + props: { + projectId: { + type: Number, + required: true, }, - data() { - return ModalStore.store; + milestonePath: { + type: String, + required: true, }, - computed: { - selectAllText() { - if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return 'Select all'; - } - - return 'Deselect all'; - }, - showSearch() { - return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; - }, + labelPath: { + type: String, + required: true, }, - methods: { - toggleAll() { - this.$refs.selectAllBtn.blur(); + }, + data() { + return ModalStore.store; + }, + computed: { + selectAllText() { + if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { + return 'Select all'; + } - ModalStore.toggleAll(); - }, + return 'Deselect all'; + }, + showSearch() { + return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; }, - components: { - 'modal-tabs': gl.issueBoards.ModalTabs, - modalFilters, + }, + methods: { + toggleAll() { + this.$refs.selectAllBtn.blur(); + + ModalStore.toggleAll(); }, - template: ` - <div> - <header class="add-issues-header form-actions"> - <h2> - Add issues - <button - type="button" - class="close" - data-dismiss="modal" - aria-label="Close" - @click="toggleModal(false)"> - <span aria-hidden="true">×</span> - </button> - </h2> - </header> - <modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs> - <div - class="add-issues-search append-bottom-10" - v-if="showSearch"> - <modal-filters :store="filter" /> + }, + components: { + 'modal-tabs': gl.issueBoards.ModalTabs, + modalFilters, + }, + template: ` + <div> + <header class="add-issues-header form-actions"> + <h2> + Add issues <button type="button" - class="btn btn-success btn-inverted prepend-left-10" - ref="selectAllBtn" - @click="toggleAll"> - {{ selectAllText }} + class="close" + data-dismiss="modal" + aria-label="Close" + @click="toggleModal(false)"> + <span aria-hidden="true">×</span> </button> - </div> + </h2> + </header> + <modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs> + <div + class="add-issues-search append-bottom-10" + v-if="showSearch"> + <modal-filters :store="filter" /> + <button + type="button" + class="btn btn-success btn-inverted prepend-left-10" + ref="selectAllBtn" + @click="toggleAll"> + {{ selectAllText }} + </button> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index 91c08cde13a..fb0aac3c0e4 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -8,160 +8,158 @@ require('./list'); require('./footer'); require('./empty_state'); -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.IssuesModal = Vue.extend({ - props: { - blankStateImage: { - type: String, - required: true, - }, - newIssuePath: { - type: String, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, +gl.issueBoards.IssuesModal = Vue.extend({ + props: { + blankStateImage: { + type: String, + required: true, }, - data() { - return ModalStore.store; + newIssuePath: { + type: String, + required: true, }, - watch: { - page() { - this.loadIssues(); - }, - showAddIssuesModal() { - if (this.showAddIssuesModal && !this.issues.length) { - this.loading = true; + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + watch: { + page() { + this.loadIssues(); + }, + showAddIssuesModal() { + if (this.showAddIssuesModal && !this.issues.length) { + this.loading = true; + + this.loadIssues() + .then(() => { + this.loading = false; + }); + } else if (!this.showAddIssuesModal) { + this.issues = []; + this.selectedIssues = []; + this.issuesCount = false; + } + }, + filter: { + handler() { + if (this.$el.tagName) { + this.page = 1; + this.filterLoading = true; - this.loadIssues() + this.loadIssues(true) .then(() => { - this.loading = false; + this.filterLoading = false; }); - } else if (!this.showAddIssuesModal) { - this.issues = []; - this.selectedIssues = []; - this.issuesCount = false; } }, - filter: { - handler() { - if (this.$el.tagName) { - this.page = 1; - this.filterLoading = true; - - this.loadIssues(true) - .then(() => { - this.filterLoading = false; - }); - } - }, - deep: true, - }, + deep: true, }, - methods: { - loadIssues(clearIssues = false) { - if (!this.showAddIssuesModal) return false; - - return gl.boardService.getBacklog(queryData(this.filter.path, { - page: this.page, - per: this.perPage, - })).then((res) => { - const data = res.json(); - - if (clearIssues) { - this.issues = []; - } + }, + methods: { + loadIssues(clearIssues = false) { + if (!this.showAddIssuesModal) return false; - data.issues.forEach((issueObj) => { - const issue = new ListIssue(issueObj); - const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; + return gl.boardService.getBacklog(queryData(this.filter.path, { + page: this.page, + per: this.perPage, + })).then((res) => { + const data = res.json(); - this.issues.push(issue); - }); + if (clearIssues) { + this.issues = []; + } - this.loadingNewPage = false; + data.issues.forEach((issueObj) => { + const issue = new ListIssue(issueObj); + const foundSelectedIssue = ModalStore.findSelectedIssue(issue); + issue.selected = !!foundSelectedIssue; - if (!this.issuesCount) { - this.issuesCount = data.size; - } + this.issues.push(issue); }); - }, - }, - computed: { - showList() { - if (this.activeTab === 'selected') { - return this.selectedIssues.length > 0; - } - return this.issuesCount > 0; - }, - showEmptyState() { - if (!this.loading && this.issuesCount === 0) { - return true; - } + this.loadingNewPage = false; - return this.activeTab === 'selected' && this.selectedIssues.length === 0; - }, + if (!this.issuesCount) { + this.issuesCount = data.size; + } + }); }, - created() { - this.page = 1; + }, + computed: { + showList() { + if (this.activeTab === 'selected') { + return this.selectedIssues.length > 0; + } + + return this.issuesCount > 0; }, - components: { - 'modal-header': gl.issueBoards.ModalHeader, - 'modal-list': gl.issueBoards.ModalList, - 'modal-footer': gl.issueBoards.ModalFooter, - 'empty-state': gl.issueBoards.ModalEmptyState, + showEmptyState() { + if (!this.loading && this.issuesCount === 0) { + return true; + } + + return this.activeTab === 'selected' && this.selectedIssues.length === 0; }, - template: ` - <div - class="add-issues-modal" - v-if="showAddIssuesModal"> - <div class="add-issues-container"> - <modal-header - :project-id="projectId" - :milestone-path="milestonePath" - :label-path="labelPath"> - </modal-header> - <modal-list - :image="blankStateImage" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - v-if="!loading && showList && !filterLoading"></modal-list> - <empty-state - v-if="showEmptyState" - :image="blankStateImage" - :new-issue-path="newIssuePath"></empty-state> - <section - class="add-issues-list text-center" - v-if="loading || filterLoading"> - <div class="add-issues-list-loading"> - <i class="fa fa-spinner fa-spin"></i> - </div> - </section> - <modal-footer></modal-footer> - </div> + }, + created() { + this.page = 1; + }, + components: { + 'modal-header': gl.issueBoards.ModalHeader, + 'modal-list': gl.issueBoards.ModalList, + 'modal-footer': gl.issueBoards.ModalFooter, + 'empty-state': gl.issueBoards.ModalEmptyState, + }, + template: ` + <div + class="add-issues-modal" + v-if="showAddIssuesModal"> + <div class="add-issues-container"> + <modal-header + :project-id="projectId" + :milestone-path="milestonePath" + :label-path="labelPath"> + </modal-header> + <modal-list + :image="blankStateImage" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + v-if="!loading && showList && !filterLoading"></modal-list> + <empty-state + v-if="showEmptyState" + :image="blankStateImage" + :new-issue-path="newIssuePath"></empty-state> + <section + class="add-issues-list text-center" + v-if="loading || filterLoading"> + <div class="add-issues-list-loading"> + <i class="fa fa-spinner fa-spin"></i> + </div> + </section> + <modal-footer></modal-footer> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js index aba56d4aa31..363269c0d5d 100644 --- a/app/assets/javascripts/boards/components/modal/list.js +++ b/app/assets/javascripts/boards/components/modal/list.js @@ -3,159 +3,157 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalList = Vue.extend({ - props: { - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - image: { - type: String, - required: true, - }, +gl.issueBoards.ModalList = Vue.extend({ + props: { + issueLinkBase: { + type: String, + required: true, }, - data() { - return ModalStore.store; + rootPath: { + type: String, + required: true, }, - watch: { - activeTab() { - if (this.activeTab === 'all') { - ModalStore.purgeUnselectedIssues(); - } - }, + image: { + type: String, + required: true, }, - computed: { - loopIssues() { - if (this.activeTab === 'all') { - return this.issues; - } - - return this.selectedIssues; - }, - groupedIssues() { - const groups = []; - this.loopIssues.forEach((issue, i) => { - const index = i % this.columns; - - if (!groups[index]) { - groups.push([]); - } - - groups[index].push(issue); - }); + }, + data() { + return ModalStore.store; + }, + watch: { + activeTab() { + if (this.activeTab === 'all') { + ModalStore.purgeUnselectedIssues(); + } + }, + }, + computed: { + loopIssues() { + if (this.activeTab === 'all') { + return this.issues; + } - return groups; - }, + return this.selectedIssues; }, - methods: { - scrollHandler() { - const currentPage = Math.floor(this.issues.length / this.perPage); + groupedIssues() { + const groups = []; + this.loopIssues.forEach((issue, i) => { + const index = i % this.columns; - if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage - && currentPage === this.page) { - this.loadingNewPage = true; - this.page += 1; + if (!groups[index]) { + groups.push([]); } - }, - toggleIssue(e, issue) { - if (e.target.tagName !== 'A') { - ModalStore.toggleIssue(issue); - } - }, - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - showIssue(issue) { - if (this.activeTab === 'all') return true; - - const index = ModalStore.selectedIssueIndex(issue); - return index !== -1; - }, - setColumnCount() { - const breakpoint = bp.getBreakpointSize(); + groups[index].push(issue); + }); - if (breakpoint === 'lg' || breakpoint === 'md') { - this.columns = 3; - } else if (breakpoint === 'sm') { - this.columns = 2; - } else { - this.columns = 1; - } - }, + return groups; }, - mounted() { - this.scrollHandlerWrapper = this.scrollHandler.bind(this); - this.setColumnCountWrapper = this.setColumnCount.bind(this); - this.setColumnCount(); + }, + methods: { + scrollHandler() { + const currentPage = Math.floor(this.issues.length / this.perPage); - this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); - window.addEventListener('resize', this.setColumnCountWrapper); + if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage + && currentPage === this.page) { + this.loadingNewPage = true; + this.page += 1; + } + }, + toggleIssue(e, issue) { + if (e.target.tagName !== 'A') { + ModalStore.toggleIssue(issue); + } + }, + listHeight() { + return this.$refs.list.getBoundingClientRect().height; }, - beforeDestroy() { - this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); - window.removeEventListener('resize', this.setColumnCountWrapper); + scrollHeight() { + return this.$refs.list.scrollHeight; }, - components: { - 'issue-card-inner': gl.issueBoards.IssueCardInner, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); }, - template: ` - <section - class="add-issues-list add-issues-list-columns" - ref="list"> + showIssue(issue) { + if (this.activeTab === 'all') return true; + + const index = ModalStore.selectedIssueIndex(issue); + + return index !== -1; + }, + setColumnCount() { + const breakpoint = bp.getBreakpointSize(); + + if (breakpoint === 'lg' || breakpoint === 'md') { + this.columns = 3; + } else if (breakpoint === 'sm') { + this.columns = 2; + } else { + this.columns = 1; + } + }, + }, + mounted() { + this.scrollHandlerWrapper = this.scrollHandler.bind(this); + this.setColumnCountWrapper = this.setColumnCount.bind(this); + this.setColumnCount(); + + this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); + window.addEventListener('resize', this.setColumnCountWrapper); + }, + beforeDestroy() { + this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); + window.removeEventListener('resize', this.setColumnCountWrapper); + }, + components: { + 'issue-card-inner': gl.issueBoards.IssueCardInner, + }, + template: ` + <section + class="add-issues-list add-issues-list-columns" + ref="list"> + <div + class="empty-state add-issues-empty-state-filter text-center" + v-if="issuesCount > 0 && issues.length === 0"> <div - class="empty-state add-issues-empty-state-filter text-center" - v-if="issuesCount > 0 && issues.length === 0"> - <div - class="svg-content" - v-html="image"> - </div> - <div class="text-content"> - <h4> - There are no issues to show. - </h4> - </div> + class="svg-content" + v-html="image"> + </div> + <div class="text-content"> + <h4> + There are no issues to show. + </h4> </div> + </div> + <div + v-for="group in groupedIssues" + class="add-issues-list-column"> <div - v-for="group in groupedIssues" - class="add-issues-list-column"> + v-for="issue in group" + v-if="showIssue(issue)" + class="card-parent"> <div - v-for="issue in group" - v-if="showIssue(issue)" - class="card-parent"> - <div - class="card" - :class="{ 'is-active': issue.selected }" - @click="toggleIssue($event, issue)"> - <issue-card-inner - :issue="issue" - :issue-link-base="issueLinkBase" - :root-path="rootPath"> - </issue-card-inner> - <span - :aria-label="'Issue #' + issue.id + ' selected'" - aria-checked="true" - v-if="issue.selected" - class="issue-card-selected text-center"> - <i class="fa fa-check"></i> - </span> - </div> + class="card" + :class="{ 'is-active': issue.selected }" + @click="toggleIssue($event, issue)"> + <issue-card-inner + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath"> + </issue-card-inner> + <span + :aria-label="'Issue #' + issue.id + ' selected'" + aria-checked="true" + v-if="issue.selected" + class="issue-card-selected text-center"> + <i class="fa fa-check"></i> + </span> </div> </div> - </section> - `, - }); -})(); + </div> + </section> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js index 9e9ed46ab8d..8cd15df90fa 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -1,57 +1,55 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; +gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + selected() { + return this.modal.selectedList || this.state.lists[0]; }, - computed: { - selected() { - return this.modal.selectedList || this.state.lists[0]; - }, - }, - destroyed() { - this.modal.selectedList = null; - }, - template: ` - <div class="dropdown inline"> - <button - class="dropdown-menu-toggle" - type="button" - data-toggle="dropdown" - aria-expanded="false"> - <span - class="dropdown-label-box" - :style="{ backgroundColor: selected.label.color }"> - </span> - {{ selected.title }} - <i class="fa fa-chevron-down"></i> - </button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> - <ul> - <li - v-for="list in state.lists" - v-if="list.type == 'label'"> - <a - href="#" - role="button" - :class="{ 'is-active': list.id == selected.id }" - @click.prevent="modal.selectedList = list"> - <span - class="dropdown-label-box" - :style="{ backgroundColor: list.label.color }"> - </span> - {{ list.title }} - </a> - </li> - </ul> - </div> + }, + destroyed() { + this.modal.selectedList = null; + }, + template: ` + <div class="dropdown inline"> + <button + class="dropdown-menu-toggle" + type="button" + data-toggle="dropdown" + aria-expanded="false"> + <span + class="dropdown-label-box" + :style="{ backgroundColor: selected.label.color }"> + </span> + {{ selected.title }} + <i class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> + <ul> + <li + v-for="list in state.lists" + v-if="list.type == 'label'"> + <a + href="#" + role="button" + :class="{ 'is-active': list.id == selected.id }" + @click.prevent="modal.selectedList = list"> + <span + class="dropdown-label-box" + :style="{ backgroundColor: list.label.color }"> + </span> + {{ list.title }} + </a> + </li> + </ul> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js index 23cb1b13d11..3e5d08e3d75 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.js +++ b/app/assets/javascripts/boards/components/modal/tabs.js @@ -1,48 +1,46 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalTabs = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return ModalStore.store; +gl.issueBoards.ModalTabs = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + computed: { + selectedCount() { + return ModalStore.selectedCount(); }, - computed: { - selectedCount() { - return ModalStore.selectedCount(); - }, - }, - destroyed() { - this.activeTab = 'all'; - }, - template: ` - <div class="top-area prepend-top-10 append-bottom-10"> - <ul class="nav-links issues-state-filters"> - <li :class="{ 'active': activeTab == 'all' }"> - <a - href="#" - role="button" - @click.prevent="changeTab('all')"> - Open issues - <span class="badge"> - {{ issuesCount }} - </span> - </a> - </li> - <li :class="{ 'active': activeTab == 'selected' }"> - <a - href="#" - role="button" - @click.prevent="changeTab('selected')"> - Selected issues - <span class="badge"> - {{ selectedCount }} - </span> - </a> - </li> - </ul> - </div> - `, - }); -})(); + }, + destroyed() { + this.activeTab = 'all'; + }, + template: ` + <div class="top-area prepend-top-10 append-bottom-10"> + <ul class="nav-links issues-state-filters"> + <li :class="{ 'active': activeTab == 'all' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('all')"> + Open issues + <span class="badge"> + {{ issuesCount }} + </span> + </a> + </li> + <li :class="{ 'active': activeTab == 'selected' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('selected')"> + Selected issues + <span class="badge"> + {{ selectedCount }} + </span> + </a> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 556826a9148..22f20305624 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -1,76 +1,74 @@ /* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */ -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - $(document).off('created.label').on('created.label', (e, label) => { - Store.new({ +$(document).off('created.label').on('created.label', (e, label) => { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, title: label.title, - position: Store.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, - title: label.title, - color: label.color - } - }); + color: label.color + } }); +}); - gl.issueBoards.newListDropdownInit = () => { - $('.js-new-board-list').each(function () { - const $this = $(this); - new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); +gl.issueBoards.newListDropdownInit = () => { + $('.js-new-board-list').each(function () { + const $this = $(this); + new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); - $this.glDropdown({ - data(term, callback) { - $.get($this.attr('data-labels')) - .then((resp) => { - callback(resp); - }); - }, - renderRow (label) { - const active = Store.findList('title', label.title); - const $li = $('<li />'); - const $a = $('<a />', { - class: (active ? `is-active js-board-list-${active.id}` : ''), - text: label.title, - href: '#' - }); - const $labelColor = $('<span />', { - class: 'dropdown-label-box', - style: `background-color: ${label.color}` + $this.glDropdown({ + data(term, callback) { + $.get($this.attr('data-labels')) + .then((resp) => { + callback(resp); }); + }, + renderRow (label) { + const active = Store.findList('title', label.title); + const $li = $('<li />'); + const $a = $('<a />', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }); + const $labelColor = $('<span />', { + class: 'dropdown-label-box', + style: `background-color: ${label.color}` + }); - return $li.append($a.prepend($labelColor)); - }, - search: { - fields: ['title'] - }, - filterable: true, - selectable: true, - multiSelect: true, - clicked (label, $el, e) { - e.preventDefault(); + return $li.append($a.prepend($labelColor)); + }, + search: { + fields: ['title'] + }, + filterable: true, + selectable: true, + multiSelect: true, + clicked (label, $el, e) { + e.preventDefault(); - if (!Store.findList('title', label.title)) { - Store.new({ + 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, - position: Store.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, - title: label.title, - color: label.color - } - }); + color: label.color + } + }); - Store.state.lists = _.sortBy(Store.state.lists, 'position'); - } + Store.state.lists = _.sortBy(Store.state.lists, 'position'); } - }); + } }); - }; -})(); + }); +}; diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js index 772ea4c5565..5597f128b80 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -3,59 +3,57 @@ import Vue from 'vue'; -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.RemoveIssueBtn = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - list: { - type: Object, - required: true, - }, +const Store = gl.issueBoards.BoardsStore; + +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; + +gl.issueBoards.RemoveIssueBtn = Vue.extend({ + props: { + issue: { + type: Object, + required: true, }, - methods: { - removeIssue() { - const issue = this.issue; - const lists = issue.getLists(); - const labelIds = lists.map(list => list.label.id); - - // Post the remove data - gl.boardService.bulkUpdate([issue.globalId], { - remove_label_ids: labelIds, - }).catch(() => { - new Flash('Failed to remove issue from board, please try again.', 'alert'); - - lists.forEach((list) => { - list.addIssue(issue); - }); - }); + list: { + type: Object, + required: true, + }, + }, + methods: { + removeIssue() { + const issue = this.issue; + const lists = issue.getLists(); + const labelIds = lists.map(list => list.label.id); + + // Post the remove data + gl.boardService.bulkUpdate([issue.globalId], { + remove_label_ids: labelIds, + }).catch(() => { + new Flash('Failed to remove issue from board, please try again.', 'alert'); - // Remove from the frontend store lists.forEach((list) => { - list.removeIssue(issue); + list.addIssue(issue); }); + }); + + // Remove from the frontend store + lists.forEach((list) => { + list.removeIssue(issue); + }); - Store.detail.issue = {}; - }, + Store.detail.issue = {}; }, - template: ` - <div - class="block list" - v-if="list.type !== 'closed'"> - <button - class="btn btn-default btn-block" - type="button" - @click="removeIssue"> - Remove from board - </button> - </div> - `, - }); -})(); + }, + template: ` + <div + class="block list" + v-if="list.type !== 'closed'"> + <button + class="btn btn-default btn-block" + type="button" + @click="removeIssue"> + Remove from board + </button> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js index d378b7d4baf..2b0a1aaa89f 100644 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js +++ b/app/assets/javascripts/boards/mixins/modal_mixins.js @@ -1,14 +1,12 @@ -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalMixins = { - methods: { - toggleModal(toggle) { - ModalStore.store.showAddIssuesModal = toggle; - }, - changeTab(tab) { - ModalStore.store.activeTab = tab; - }, +gl.issueBoards.ModalMixins = { + methods: { + toggleModal(toggle) { + ModalStore.store.showAddIssuesModal = toggle; }, - }; -})(); + changeTab(tab) { + ModalStore.store.activeTab = tab; + }, + }, +}; diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index b6c6d17274f..38a0eb12f92 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,39 +1,37 @@ /* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ /* global DocumentTouch */ -((w) => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.onStart = () => { - $('.has-tooltip').tooltip('hide') - .tooltip('disable'); - document.body.classList.add('is-dragging'); - }; - - gl.issueBoards.onEnd = () => { - $('.has-tooltip').tooltip('enable'); - document.body.classList.remove('is-dragging'); - }; +gl.issueBoards.onStart = () => { + $('.has-tooltip').tooltip('hide') + .tooltip('disable'); + document.body.classList.add('is-dragging'); +}; - gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; +gl.issueBoards.onEnd = () => { + $('.has-tooltip').tooltip('enable'); + document.body.classList.remove('is-dragging'); +}; - gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { - const defaultSortOptions = { - animation: 200, - forceFallback: true, - fallbackClass: 'is-dragging', - fallbackOnBody: true, - ghostClass: 'is-ghost', - filter: '.board-delete, .btn', - delay: gl.issueBoards.touchEnabled ? 100 : 0, - scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, - scrollSpeed: 20, - onStart: gl.issueBoards.onStart, - onEnd: gl.issueBoards.onEnd - }; +gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; - Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); - return defaultSortOptions; +gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { + const defaultSortOptions = { + animation: 200, + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + filter: '.board-delete, .btn', + delay: gl.issueBoards.touchEnabled ? 100 : 0, + scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, + scrollSpeed: 20, + onStart: gl.issueBoards.onStart, + onEnd: gl.issueBoards.onEnd }; -})(window); + + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); + return defaultSortOptions; +}; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index bcda70d0638..66384d9c038 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -3,125 +3,123 @@ import Cookies from 'js-cookie'; -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.BoardsStore = { - disabled: false, - filter: { - path: '', - }, - state: {}, - detail: { - issue: {} - }, - moving: { - issue: {}, - list: {} - }, - create () { - this.state.lists = []; - this.filter.path = gl.utils.getUrlParamsArray().join('&'); - }, - addList (listObj) { - const list = new List(listObj); - this.state.lists.push(list); +gl.issueBoards.BoardsStore = { + disabled: false, + filter: { + path: '', + }, + state: {}, + detail: { + issue: {} + }, + moving: { + issue: {}, + list: {} + }, + create () { + this.state.lists = []; + this.filter.path = gl.utils.getUrlParamsArray().join('&'); + }, + addList (listObj) { + const list = new List(listObj); + this.state.lists.push(list); - return list; - }, - new (listObj) { - const list = this.addList(listObj); + return list; + }, + new (listObj) { + const list = this.addList(listObj); - list - .save() - .then(() => { - this.state.lists = _.sortBy(this.state.lists, 'position'); - }); - 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 !== 'closed')[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 + list + .save() + .then(() => { + this.state.lists = _.sortBy(this.state.lists, 'position'); }); + 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 !== 'closed')[0]); + }, + addBlankState () { + if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - this.state.lists = _.sortBy(this.state.lists, 'position'); - }, - removeBlankState () { - this.removeList('blank'); - - Cookies.set('issue_board_welcome_hidden', 'true', { - expires: 365 * 10, - path: '' - }); - }, - welcomeIsHidden () { - return Cookies.get('issue_board_welcome_hidden') === 'true'; - }, - removeList (id, type = 'blank') { - const list = this.findList('id', id, type); + this.addList({ + id: 'blank', + list_type: 'blank', + title: 'Welcome to your Issue Board!', + position: 0 + }); - if (!list) return; + this.state.lists = _.sortBy(this.state.lists, 'position'); + }, + removeBlankState () { + this.removeList('blank'); - 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, 10)); + Cookies.set('issue_board_welcome_hidden', 'true', { + expires: 365 * 10, + path: '' + }); + }, + welcomeIsHidden () { + return Cookies.get('issue_board_welcome_hidden') === 'true'; + }, + removeList (id, type = 'blank') { + const list = this.findList('id', id, type); - list.position = i; - }); - listFrom.update(); - }, - moveIssueToList (listFrom, listTo, issue, newIndex) { - const issueTo = listTo.findIssue(issue.id); - const issueLists = issue.getLists(); - const listLabels = issueLists.map(listIssue => listIssue.label); + if (!list) return; - if (!issueTo) { - // Add to new lists issues if it doesn't already exist - listTo.addIssue(issue, listFrom, newIndex); - } else { - listTo.updateIssueLabel(issue, listFrom); - issueTo.removeLabel(listFrom.label); - } + 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, 10)); - if (listTo.type === 'closed') { - issueLists.forEach((list) => { - list.removeIssue(issue); - }); - issue.removeLabels(listLabels); - } else { - listFrom.removeIssue(issue); - } - }, - moveIssueInList (list, issue, oldIndex, newIndex, idArray) { - const beforeId = parseInt(idArray[newIndex - 1], 10) || null; - const afterId = parseInt(idArray[newIndex + 1], 10) || null; + list.position = i; + }); + listFrom.update(); + }, + moveIssueToList (listFrom, listTo, issue, newIndex) { + const issueTo = listTo.findIssue(issue.id); + const issueLists = issue.getLists(); + const listLabels = issueLists.map(listIssue => listIssue.label); - list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); - }, - findList (key, val, type = 'label') { - return this.state.lists.filter((list) => { - const byType = type ? list['type'] === type : true; + if (!issueTo) { + // Add to new lists issues if it doesn't already exist + listTo.addIssue(issue, listFrom, newIndex); + } else { + listTo.updateIssueLabel(issue, listFrom); + issueTo.removeLabel(listFrom.label); + } - return list[key] === val && byType; - })[0]; - }, - updateFiltersUrl () { - history.pushState(null, null, `?${this.filter.path}`); + if (listTo.type === 'closed') { + issueLists.forEach((list) => { + list.removeIssue(issue); + }); + issue.removeLabels(listLabels); + } else { + listFrom.removeIssue(issue); } - }; -})(); + }, + moveIssueInList (list, issue, oldIndex, newIndex, idArray) { + const beforeId = parseInt(idArray[newIndex - 1], 10) || null; + const afterId = parseInt(idArray[newIndex + 1], 10) || null; + + list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); + }, + findList (key, val, type = 'label') { + return this.state.lists.filter((list) => { + const byType = type ? list['type'] === type : true; + + return list[key] === val && byType; + })[0]; + }, + updateFiltersUrl () { + history.pushState(null, null, `?${this.filter.path}`); + } +}; diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index 9b009483a3c..4fdc925c825 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -1,100 +1,98 @@ -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - class ModalStore { - constructor() { - this.store = { - columns: 3, - issues: [], - issuesCount: false, - selectedIssues: [], - showAddIssuesModal: false, - activeTab: 'all', - selectedList: null, - searchTerm: '', - loading: false, - loadingNewPage: false, - filterLoading: false, - page: 1, - perPage: 50, - filter: { - path: '', - }, - }; - } +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; + +class ModalStore { + constructor() { + this.store = { + columns: 3, + issues: [], + issuesCount: false, + selectedIssues: [], + showAddIssuesModal: false, + activeTab: 'all', + selectedList: null, + searchTerm: '', + loading: false, + loadingNewPage: false, + filterLoading: false, + page: 1, + perPage: 50, + filter: { + path: '', + }, + }; + } - selectedCount() { - return this.getSelectedIssues().length; - } + selectedCount() { + return this.getSelectedIssues().length; + } - toggleIssue(issueObj) { - const issue = issueObj; - const selected = issue.selected; + toggleIssue(issueObj) { + const issue = issueObj; + const selected = issue.selected; - issue.selected = !selected; + issue.selected = !selected; - if (!selected) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } + if (!selected) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); } + } - toggleAll() { - const select = this.selectedCount() !== this.store.issues.length; + toggleAll() { + const select = this.selectedCount() !== this.store.issues.length; - this.store.issues.forEach((issue) => { - const issueUpdate = issue; + this.store.issues.forEach((issue) => { + const issueUpdate = issue; - if (issueUpdate.selected !== select) { - issueUpdate.selected = select; + if (issueUpdate.selected !== select) { + issueUpdate.selected = select; - if (select) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } + if (select) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); } - }); - } + } + }); + } - getSelectedIssues() { - return this.store.selectedIssues.filter(issue => issue.selected); - } + getSelectedIssues() { + return this.store.selectedIssues.filter(issue => issue.selected); + } - addSelectedIssue(issue) { - const index = this.selectedIssueIndex(issue); + addSelectedIssue(issue) { + const index = this.selectedIssueIndex(issue); - if (index === -1) { - this.store.selectedIssues.push(issue); - } + if (index === -1) { + this.store.selectedIssues.push(issue); } + } - removeSelectedIssue(issue, forcePurge = false) { - if (this.store.activeTab === 'all' || forcePurge) { - this.store.selectedIssues = this.store.selectedIssues - .filter(fIssue => fIssue.id !== issue.id); - } + removeSelectedIssue(issue, forcePurge = false) { + if (this.store.activeTab === 'all' || forcePurge) { + this.store.selectedIssues = this.store.selectedIssues + .filter(fIssue => fIssue.id !== issue.id); } + } - purgeUnselectedIssues() { - this.store.selectedIssues.forEach((issue) => { - if (!issue.selected) { - this.removeSelectedIssue(issue, true); - } - }); - } + purgeUnselectedIssues() { + this.store.selectedIssues.forEach((issue) => { + if (!issue.selected) { + this.removeSelectedIssue(issue, true); + } + }); + } - selectedIssueIndex(issue) { - return this.store.selectedIssues.indexOf(issue); - } + selectedIssueIndex(issue) { + return this.store.selectedIssues.indexOf(issue); + } - findSelectedIssue(issue) { - return this.store.selectedIssues - .filter(filteredIssue => filteredIssue.id === issue.id)[0]; - } + findSelectedIssue(issue) { + return this.store.selectedIssues + .filter(filteredIssue => filteredIssue.id === issue.id)[0]; } +} - gl.issueBoards.ModalStore = new ModalStore(); -})(); +gl.issueBoards.ModalStore = new ModalStore(); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 3f419a96ff9..80bd2df6f42 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -2,46 +2,45 @@ import Vue from 'vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageCodeComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="mergeRequest in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="mergeRequest.author.avatarUrl"> - <h5 class="item-title merge-merquest-title"> - <a :href="mergeRequest.url"> - {{ mergeRequest.title }} - </a> - </h5> - <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> - · - <span> - Opened - <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> - </span> - <span> - by - <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> - </span> - </div> - <div class="item-time"> - <total-time :time="mergeRequest.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageCodeComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="mergeRequest in items" class="stage-event-item"> + <div class="item-details"> + <img class="avatar" :src="mergeRequest.author.avatarUrl"> + <h5 class="item-title merge-merquest-title"> + <a :href="mergeRequest.url"> + {{ mergeRequest.title }} + </a> + </h5> + <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> + · + <span> + Opened + <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> + </span> + <span> + by + <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> + </span> + </div> + <div class="item-time"> + <total-time :time="mergeRequest.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index 7ffa38edd9e..20a43798fbe 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -2,48 +2,47 @@ import Vue from 'vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageIssueComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="issue in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="issue.author.avatarUrl"> - <h5 class="item-title issue-title"> - <a class="issue-title" :href="issue.url"> - {{ issue.title }} - </a> - </h5> - <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> - · - <span> - Opened - <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> - </span> - <span> - by - <a :href="issue.author.webUrl" class="issue-author-link"> - {{ issue.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="issue.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageIssueComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="issue in items" class="stage-event-item"> + <div class="item-details"> + <img class="avatar" :src="issue.author.avatarUrl"> + <h5 class="item-title issue-title"> + <a class="issue-title" :href="issue.url"> + {{ issue.title }} + </a> + </h5> + <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> + · + <span> + Opened + <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> + </span> + <span> + by + <a :href="issue.author.webUrl" class="issue-author-link"> + {{ issue.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="issue.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index d736c8b0c28..f33cac3da82 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -2,50 +2,49 @@ import Vue from 'vue'; import iconCommit from '../svg/icon_commit.svg'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StagePlanComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, +global.cycleAnalytics.StagePlanComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, - data() { - return { iconCommit }; - }, + data() { + return { iconCommit }; + }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="commit in items" class="stage-event-item"> - <div class="item-details item-conmmit-component"> - <img class="avatar" :src="commit.author.avatarUrl"> - <h5 class="item-title commit-title"> - <a :href="commit.commitUrl"> - {{ commit.title }} - </a> - </h5> - <span> - First - <span class="commit-icon">${iconCommit}</span> - <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - pushed by - <a :href="commit.author.webUrl" class="commit-author-link"> - {{ commit.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="commit.totalTime"></total-time> - </div> - </li> - </ul> + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="commit in items" class="stage-event-item"> + <div class="item-details item-conmmit-component"> + <img class="avatar" :src="commit.author.avatarUrl"> + <h5 class="item-title commit-title"> + <a :href="commit.commitUrl"> + {{ commit.title }} + </a> + </h5> + <span> + First + <span class="commit-icon">${iconCommit}</span> + <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> + pushed by + <a :href="commit.author.webUrl" class="commit-author-link"> + {{ commit.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="commit.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 698a79ca68c..657f5385374 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -2,48 +2,47 @@ import Vue from 'vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageProductionComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="issue in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="issue.author.avatarUrl"> - <h5 class="item-title issue-title"> - <a class="issue-title" :href="issue.url"> - {{ issue.title }} - </a> - </h5> - <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> - · - <span> - Opened - <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> - </span> - <span> - by - <a :href="issue.author.webUrl" class="issue-author-link"> - {{ issue.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="issue.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageProductionComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="issue in items" class="stage-event-item"> + <div class="item-details"> + <img class="avatar" :src="issue.author.avatarUrl"> + <h5 class="item-title issue-title"> + <a class="issue-title" :href="issue.url"> + {{ issue.title }} + </a> + </h5> + <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> + · + <span> + Opened + <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> + </span> + <span> + by + <a :href="issue.author.webUrl" class="issue-author-link"> + {{ issue.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="issue.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index e63c41f2a57..8a801300647 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -2,58 +2,57 @@ import Vue from 'vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageReviewComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="mergeRequest in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="mergeRequest.author.avatarUrl"> - <h5 class="item-title merge-merquest-title"> - <a :href="mergeRequest.url"> - {{ mergeRequest.title }} - </a> - </h5> - <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> - · - <span> - Opened - <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> +global.cycleAnalytics.StageReviewComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> + </div> + <ul class="stage-event-list"> + <li v-for="mergeRequest in items" class="stage-event-item"> + <div class="item-details"> + <img class="avatar" :src="mergeRequest.author.avatarUrl"> + <h5 class="item-title merge-merquest-title"> + <a :href="mergeRequest.url"> + {{ mergeRequest.title }} + </a> + </h5> + <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> + · + <span> + Opened + <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> + </span> + <span> + by + <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> + </span> + <template v-if="mergeRequest.state === 'closed'"> + <span class="merge-request-state"> + <i class="fa fa-ban"></i> + {{ mergeRequest.state.toUpperCase() }} </span> - <span> - by - <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> + </template> + <template v-else> + <span class="merge-request-branch" v-if="mergeRequest.branch"> + <i class= "fa fa-code-fork"></i> + <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a> </span> - <template v-if="mergeRequest.state === 'closed'"> - <span class="merge-request-state"> - <i class="fa fa-ban"></i> - {{ mergeRequest.state.toUpperCase() }} - </span> - </template> - <template v-else> - <span class="merge-request-branch" v-if="mergeRequest.branch"> - <i class= "fa fa-code-fork"></i> - <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a> - </span> - </template> - </div> - <div class="item-time"> - <total-time :time="mergeRequest.totalTime"></total-time> - </div> - </li> - </ul> - </div> - `, - }); -})(window.gl || (window.gl = {})); + </template> + </div> + <div class="item-time"> + <total-time :time="mergeRequest.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index d51f7134e25..4a286379588 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -2,48 +2,47 @@ import Vue from 'vue'; import iconBranch from '../svg/icon_branch.svg'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageStagingComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - data() { - return { iconBranch }; - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="build in items" class="stage-event-item item-build-component"> - <div class="item-details"> - <img class="avatar" :src="build.author.avatarUrl"> - <h5 class="item-title"> - <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> - <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> - <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> - </h5> - <span> - <a :href="build.url" class="build-date">{{ build.date }}</a> - by - <a :href="build.author.webUrl" class="issue-author-link"> - {{ build.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="build.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageStagingComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + data() { + return { iconBranch }; + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="build in items" class="stage-event-item item-build-component"> + <div class="item-details"> + <img class="avatar" :src="build.author.avatarUrl"> + <h5 class="item-title"> + <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> + <i class="fa fa-code-fork"></i> + <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> + <span class="icon-branch">${iconBranch}</span> + <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> + </h5> + <span> + <a :href="build.url" class="build-date">{{ build.date }}</a> + by + <a :href="build.author.webUrl" class="issue-author-link"> + {{ build.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="build.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js index 17ae3a9ddc1..e306026429e 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js @@ -3,48 +3,47 @@ import Vue from 'vue'; import iconBuildStatus from '../svg/icon_build_status.svg'; import iconBranch from '../svg/icon_branch.svg'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageTestComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - data() { - return { iconBuildStatus, iconBranch }; - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="build in items" class="stage-event-item item-build-component"> - <div class="item-details"> - <h5 class="item-title"> - <span class="icon-build-status">${iconBuildStatus}</span> - <a :href="build.url" class="item-build-name">{{ build.name }}</a> - · - <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> - <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> - <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> - </h5> - <span> - <a :href="build.url" class="issue-date"> - {{ build.date }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="build.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageTestComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + data() { + return { iconBuildStatus, iconBranch }; + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="build in items" class="stage-event-item item-build-component"> + <div class="item-details"> + <h5 class="item-title"> + <span class="icon-build-status">${iconBuildStatus}</span> + <a :href="build.url" class="item-build-name">{{ build.name }}</a> + · + <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> + <i class="fa fa-code-fork"></i> + <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> + <span class="icon-branch">${iconBranch}</span> + <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> + </h5> + <span> + <a :href="build.url" class="issue-date"> + {{ build.date }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="build.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index b4442ea5566..77edcb76273 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -2,25 +2,24 @@ import Vue from 'vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.TotalTimeComponent = Vue.extend({ - props: { - time: Object, - }, - template: ` - <span class="total-time"> - <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>hr</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> - <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> - </template> - <template v-else> - -- - </template> - </span> - `, - }); -})(window.gl || (window.gl = {})); +global.cycleAnalytics.TotalTimeComponent = Vue.extend({ + props: { + time: Object, + }, + template: ` + <span class="total-time"> + <template v-if="Object.keys(time).length"> + <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> + <template v-if="time.hours">{{ time.hours }} <span>hr</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> + <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> + </template> + <template v-else> + -- + </template> + </span> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index 9f74b14c4b9..681d6eef565 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -1,41 +1,41 @@ /* eslint-disable no-param-reassign */ -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - class CycleAnalyticsService { - constructor(options) { - this.requestPath = options.requestPath; - } +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - fetchCycleAnalyticsData(options) { - options = options || { startDate: 30 }; - - return $.ajax({ - url: this.requestPath, - method: 'GET', - dataType: 'json', - contentType: 'application/json', - data: { - cycle_analytics: { - start_date: options.startDate, - }, - }, - }); - } +class CycleAnalyticsService { + constructor(options) { + this.requestPath = options.requestPath; + } - fetchStageData(options) { - const { - stage, - startDate, - } = options; + fetchCycleAnalyticsData(options) { + options = options || { startDate: 30 }; - return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + return $.ajax({ + url: this.requestPath, + method: 'GET', + dataType: 'json', + contentType: 'application/json', + data: { cycle_analytics: { - start_date: startDate, + start_date: options.startDate, }, - }); - } + }, + }); + } + + fetchStageData(options) { + const { + stage, + startDate, + } = options; + + return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + cycle_analytics: { + start_date: startDate, + }, + }); } +} - global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; -})(window.gl || (window.gl = {})); +global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 7ae9de7297c..6536a8fd7fa 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -3,102 +3,101 @@ require('../lib/utils/text_utility'); const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - const EMPTY_STAGE_TEXTS = { - issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', - }; +const EMPTY_STAGE_TEXTS = { + issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', + plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', + code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', + test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', + review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', + staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', + production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', +}; - global.cycleAnalytics.CycleAnalyticsStore = { - state: { - summary: '', - stats: '', - analytics: '', - events: [], - stages: [], - }, - setCycleAnalyticsData(data) { - this.state = Object.assign(this.state, this.decorateData(data)); - }, - decorateData(data) { - const newData = {}; +global.cycleAnalytics.CycleAnalyticsStore = { + state: { + summary: '', + stats: '', + analytics: '', + events: [], + stages: [], + }, + setCycleAnalyticsData(data) { + this.state = Object.assign(this.state, this.decorateData(data)); + }, + decorateData(data) { + const newData = {}; - newData.stages = data.stats || []; - newData.summary = data.summary || []; + newData.stages = data.stats || []; + newData.summary = data.summary || []; - newData.summary.forEach((item) => { - item.value = item.value || '-'; - }); + newData.summary.forEach((item) => { + item.value = item.value || '-'; + }); - newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.title.toLowerCase()); - item.active = false; - item.isUserAllowed = data.permissions[stageSlug]; - item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; - item.component = `stage-${stageSlug}-component`; - item.slug = stageSlug; - }); - newData.analytics = data; - return newData; - }, - setLoadingState(state) { - this.state.isLoading = state; - }, - setErrorState(state) { - this.state.hasError = state; - }, - deactivateAllStages() { - this.state.stages.forEach((stage) => { - stage.active = false; - }); - }, - setActiveStage(stage) { - this.deactivateAllStages(); - stage.active = true; - }, - setStageEvents(events, stage) { - this.state.events = this.decorateEvents(events, stage); - }, - decorateEvents(events, stage) { - const newEvents = []; + newData.stages.forEach((item) => { + const stageSlug = gl.text.dasherize(item.title.toLowerCase()); + item.active = false; + item.isUserAllowed = data.permissions[stageSlug]; + item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; + item.component = `stage-${stageSlug}-component`; + item.slug = stageSlug; + }); + newData.analytics = data; + return newData; + }, + setLoadingState(state) { + this.state.isLoading = state; + }, + setErrorState(state) { + this.state.hasError = state; + }, + deactivateAllStages() { + this.state.stages.forEach((stage) => { + stage.active = false; + }); + }, + setActiveStage(stage) { + this.deactivateAllStages(); + stage.active = true; + }, + setStageEvents(events, stage) { + this.state.events = this.decorateEvents(events, stage); + }, + decorateEvents(events, stage) { + const newEvents = []; - events.forEach((item) => { - if (!item) return; + events.forEach((item) => { + if (!item) return; - const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); + const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); - eventItem.totalTime = eventItem.total_time; + eventItem.totalTime = eventItem.total_time; - if (eventItem.author) { - eventItem.author.webUrl = eventItem.author.web_url; - eventItem.author.avatarUrl = eventItem.author.avatar_url; - } + if (eventItem.author) { + eventItem.author.webUrl = eventItem.author.web_url; + eventItem.author.avatarUrl = eventItem.author.avatar_url; + } - if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; - if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; - if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; + if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; + if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; + if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; - delete eventItem.author.web_url; - delete eventItem.author.avatar_url; - delete eventItem.total_time; - delete eventItem.created_at; - delete eventItem.short_sha; - delete eventItem.commit_url; + delete eventItem.author.web_url; + delete eventItem.author.avatar_url; + delete eventItem.total_time; + delete eventItem.created_at; + delete eventItem.short_sha; + delete eventItem.commit_url; - newEvents.push(eventItem); - }); + newEvents.push(eventItem); + }); - return newEvents; - }, - currentActiveStage() { - return this.state.stages.find(stage => stage.active); - }, - }; -})(window.gl || (window.gl = {})); + return newEvents; + }, + currentActiveStage() { + return this.state.stages.find(stage => stage.active); + }, +}; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 9ac4c49d697..b62b2cec4d8 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -50,7 +50,7 @@ window.gl.GfmAutoComplete = { template: '<li>${title}</li>' }, Loading: { - template: '<li style="pointer-events: none;"><i class="fa fa-refresh fa-spin"></i> Loading...</li>' + template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>' }, DefaultOptions: { sorter: function(query, items, searchKey) { diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 47e675f537e..011043e992f 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -20,57 +20,60 @@ class Issue { }); Issue.initIssueBtnEventListeners(); } + + Issue.$btnNewBranch = $('#new-branch'); + Issue.initMergeRequests(); Issue.initRelatedBranches(); Issue.initCanCreateBranch(); } static initIssueBtnEventListeners() { - var issueFailMessage; - issueFailMessage = 'Unable to update this issue at this time.'; - return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, isClose, shouldSubmit, url; + const issueFailMessage = 'Unable to update this issue at this time.'; + + const closeButtons = $('a.btn-close'); + const isClosedBadge = $('div.status-box-closed'); + const isOpenBadge = $('div.status-box-open'); + const projectIssuesCounter = $('.issue_counter'); + const reopenButtons = $('a.btn-reopen'); + + return closeButtons.add(reopenButtons).on('click', function(e) { + var $this, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); $this = $(this); - isClose = $this.hasClass('btn-close'); shouldSubmit = $this.hasClass('btn-comment'); if (shouldSubmit) { Issue.submitNoteForm($this.closest('form')); } $this.prop('disabled', true); + Issue.setNewBranchButtonState(true, null); url = $this.attr('href'); return $.ajax({ type: 'PUT', - url: url, - error: function(jqXHR, textStatus, errorThrown) { - var issueStatus; - issueStatus = isClose ? 'close' : 'open'; - return new Flash(issueFailMessage, 'alert'); - }, - success: function(data, textStatus, jqXHR) { - if ('id' in data) { - $(document).trigger('issuable:change'); - let total = Number($('.issue_counter').text().replace(/[^\d]/, '')); - if (isClose) { - $('a.btn-close').addClass('hidden'); - $('a.btn-reopen').removeClass('hidden'); - $('div.status-box-closed').removeClass('hidden'); - $('div.status-box-open').addClass('hidden'); - total -= 1; - } else { - $('a.btn-reopen').addClass('hidden'); - $('a.btn-close').removeClass('hidden'); - $('div.status-box-closed').addClass('hidden'); - $('div.status-box-open').removeClass('hidden'); - total += 1; - } - $('.issue_counter').text(gl.text.addDelimiter(total)); - } else { - new Flash(issueFailMessage, 'alert'); - } - return $this.prop('disabled', false); + url: url + }).fail(function(jqXHR, textStatus, errorThrown) { + new Flash(issueFailMessage); + Issue.initCanCreateBranch(); + }).done(function(data, textStatus, jqXHR) { + if ('id' in data) { + $(document).trigger('issuable:change'); + + const isClosed = $this.hasClass('btn-close'); + closeButtons.toggleClass('hidden', isClosed); + reopenButtons.toggleClass('hidden', !isClosed); + isClosedBadge.toggleClass('hidden', !isClosed); + isOpenBadge.toggleClass('hidden', isClosed); + + let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); + numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; + projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + } else { + new Flash(issueFailMessage); } + + $this.prop('disabled', false); + Issue.initCanCreateBranch(); }); }); } @@ -86,9 +89,9 @@ class Issue { static initMergeRequests() { var $container; $container = $('#merge-requests'); - return $.getJSON($container.data('url')).error(function() { - return new Flash('Failed to load referenced merge requests', 'alert'); - }).success(function(data) { + return $.getJSON($container.data('url')).fail(function() { + return new Flash('Failed to load referenced merge requests'); + }).done(function(data) { if ('html' in data) { return $container.html(data.html); } @@ -98,9 +101,9 @@ class Issue { static initRelatedBranches() { var $container; $container = $('#related-branches'); - return $.getJSON($container.data('url')).error(function() { - return new Flash('Failed to load related branches', 'alert'); - }).success(function(data) { + return $.getJSON($container.data('url')).fail(function() { + return new Flash('Failed to load related branches'); + }).done(function(data) { if ('html' in data) { return $container.html(data.html); } @@ -108,24 +111,27 @@ class Issue { } static initCanCreateBranch() { - var $container; - $container = $('#new-branch'); // If the user doesn't have the required permissions the container isn't // rendered at all. - if ($container.length === 0) { + if (Issue.$btnNewBranch.length === 0) { return; } - return $.getJSON($container.data('path')).error(function() { - $container.find('.unavailable').show(); - return new Flash('Failed to check if a new branch can be created.', 'alert'); - }).success(function(data) { - if (data.can_create_branch) { - $container.find('.available').show(); - } else { - return $container.find('.unavailable').show(); - } + return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() { + Issue.setNewBranchButtonState(false, false); + new Flash('Failed to check if a new branch can be created.'); + }).done(function(data) { + Issue.setNewBranchButtonState(false, data.can_create_branch); }); } + + static setNewBranchButtonState(isPending, canCreate) { + if (Issue.$btnNewBranch.length === 0) { + return; + } + + Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate); + Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate); + } } export default Issue; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 2e5f8a09fc1..fecd531328d 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,192 +1,188 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len */ - +/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ require('vendor/latinise'); -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; - } - if ((base = w.gl).text == null) { - base.text = {}; +var base; +var w = window; +if (w.gl == null) { + w.gl = {}; +} +if ((base = w.gl).text == null) { + base.text = {}; +} +gl.text.addDelimiter = function(text) { + return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text; +}; +gl.text.highCountTrim = function(count) { + return count > 99 ? '99+' : count; +}; +gl.text.randomString = function() { + return Math.random().toString(36).substring(7); +}; +gl.text.replaceRange = function(s, start, end, substitute) { + return s.substring(0, start) + substitute + s.substring(end); +}; +gl.text.getTextWidth = function(text, font) { + /** + * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. + * + * @param {String} text The text to be rendered. + * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). + * + * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 + */ + // re-use canvas object for better performance + var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas')); + var context = canvas.getContext('2d'); + context.font = font; + return context.measureText(text).width; +}; +gl.text.selectedText = function(text, textarea) { + return text.substring(textarea.selectionStart, textarea.selectionEnd); +}; +gl.text.lineBefore = function(text, textarea) { + var split; + split = text.substring(0, textarea.selectionStart).trim().split('\n'); + return split[split.length - 1]; +}; +gl.text.lineAfter = function(text, textarea) { + return text.substring(textarea.selectionEnd).trim().split('\n')[0]; +}; +gl.text.blockTagText = function(text, textArea, blockTag, selected) { + var lineAfter, lineBefore; + lineBefore = this.lineBefore(text, textArea); + lineAfter = this.lineAfter(text, textArea); + if (lineBefore === blockTag && lineAfter === blockTag) { + // To remove the block tag we have to select the line before & after + if (blockTag != null) { + textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); + textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); } - gl.text.addDelimiter = function(text) { - return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text; - }; - gl.text.highCountTrim = function(count) { - return count > 99 ? '99+' : count; - }; - gl.text.randomString = function() { - return Math.random().toString(36).substring(7); - }; - gl.text.replaceRange = function(s, start, end, substitute) { - return s.substring(0, start) + substitute + s.substring(end); - }; - gl.text.getTextWidth = function(text, font) { - /** - * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. - * - * @param {String} text The text to be rendered. - * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). - * - * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 - */ - // re-use canvas object for better performance - var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas')); - var context = canvas.getContext('2d'); - context.font = font; - return context.measureText(text).width; - }; - gl.text.selectedText = function(text, textarea) { - return text.substring(textarea.selectionStart, textarea.selectionEnd); - }; - gl.text.lineBefore = function(text, textarea) { - var split; - split = text.substring(0, textarea.selectionStart).trim().split('\n'); - return split[split.length - 1]; - }; - gl.text.lineAfter = function(text, textarea) { - return text.substring(textarea.selectionEnd).trim().split('\n')[0]; - }; - gl.text.blockTagText = function(text, textArea, blockTag, selected) { - var lineAfter, lineBefore; - lineBefore = this.lineBefore(text, textArea); - lineAfter = this.lineAfter(text, textArea); - if (lineBefore === blockTag && lineAfter === blockTag) { - // To remove the block tag we have to select the line before & after - if (blockTag != null) { - textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); - textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); - } - return selected; - } else { - return blockTag + "\n" + selected + "\n" + blockTag; - } - }; - gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { - var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; - removedLastNewLine = false; - removedFirstNewLine = false; - currentLineEmpty = false; + return selected; + } else { + return blockTag + "\n" + selected + "\n" + blockTag; + } +}; +gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { + var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; + removedLastNewLine = false; + removedFirstNewLine = false; + currentLineEmpty = false; - // Remove the first newline - if (selected.indexOf('\n') === 0) { - removedFirstNewLine = true; - selected = selected.replace(/\n+/, ''); - } + // Remove the first newline + if (selected.indexOf('\n') === 0) { + removedFirstNewLine = true; + selected = selected.replace(/\n+/, ''); + } - // Remove the last newline - if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { - removedLastNewLine = true; - selected = selected.replace(/\n$/, ''); - } + // Remove the last newline + if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } - selectedSplit = selected.split('\n'); + selectedSplit = selected.split('\n'); - if (!wrap) { - lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); + if (!wrap) { + lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); - // Check whether the current line is empty or consists only of spaces(=handle as empty) - if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { - currentLineEmpty = true; - } - } + // Check whether the current line is empty or consists only of spaces(=handle as empty) + if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { + currentLineEmpty = true; + } + } - startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; - if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) { - if (blockTag != null) { - insertText = this.blockTagText(text, textArea, blockTag, selected); + if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) { + if (blockTag != null) { + insertText = this.blockTagText(text, textArea, blockTag, selected); + } else { + insertText = selectedSplit.map(function(val) { + if (val.indexOf(tag) === 0) { + return "" + (val.replace(tag, '')); } else { - insertText = selectedSplit.map(function(val) { - if (val.indexOf(tag) === 0) { - return "" + (val.replace(tag, '')); - } else { - return "" + tag + val; - } - }).join('\n'); + return "" + tag + val; } - } else { - insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); - } + }).join('\n'); + } + } else { + insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); + } - if (removedFirstNewLine) { - insertText = '\n' + insertText; - } + if (removedFirstNewLine) { + insertText = '\n' + insertText; + } - if (removedLastNewLine) { - insertText += '\n'; - } + if (removedLastNewLine) { + insertText += '\n'; + } - if (document.queryCommandSupported('insertText')) { - inserted = document.execCommand('insertText', false, insertText); - } - if (!inserted) { - try { - document.execCommand("ms-beginUndoUnit"); - } catch (error) {} - textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); - try { - document.execCommand("ms-endUndoUnit"); - } catch (error) {} - } - return this.moveCursor(textArea, tag, wrap, removedLastNewLine); - }; - gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { - var pos; - if (!textArea.setSelectionRange) { - return; - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (wrapped) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; - } + if (document.queryCommandSupported('insertText')) { + inserted = document.execCommand('insertText', false, insertText); + } + if (!inserted) { + try { + document.execCommand("ms-beginUndoUnit"); + } catch (error) {} + textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); + try { + document.execCommand("ms-endUndoUnit"); + } catch (error) {} + } + return this.moveCursor(textArea, tag, wrap, removedLastNewLine); +}; +gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { + var pos; + if (!textArea.setSelectionRange) { + return; + } + if (textArea.selectionStart === textArea.selectionEnd) { + if (wrapped) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } - if (removedLastNewLine) { - pos -= 1; - } + if (removedLastNewLine) { + pos -= 1; + } - return textArea.setSelectionRange(pos, pos); - } - }; - gl.text.updateText = function(textArea, tag, blockTag, wrap) { - var $textArea, selected, text; - $textArea = $(textArea); - textArea = $textArea.get(0); - text = $textArea.val(); - selected = this.selectedText(text, textArea); - $textArea.focus(); - return this.insertText(textArea, text, tag, blockTag, selected, wrap); - }; - gl.text.init = function(form) { - var self; - self = this; - return $('.js-md', form).off('click').on('click', function() { - var $this; - $this = $(this); - return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); - }); - }; - gl.text.removeListeners = function(form) { - return $('.js-md', form).off(); - }; - gl.text.humanize = function(string) { - return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); - }; - gl.text.pluralize = function(str, count) { - return str + (count > 1 || count === 0 ? 's' : ''); - }; - gl.text.truncate = function(string, maxLength) { - return string.substr(0, (maxLength - 3)) + '...'; - }; - gl.text.dasherize = function(str) { - return str.replace(/[_\s]+/g, '-'); - }; - gl.text.slugify = function(str) { - return str.trim().toLowerCase().latinise(); - }; - })(window); -}).call(window); + return textArea.setSelectionRange(pos, pos); + } +}; +gl.text.updateText = function(textArea, tag, blockTag, wrap) { + var $textArea, selected, text; + $textArea = $(textArea); + textArea = $textArea.get(0); + text = $textArea.val(); + selected = this.selectedText(text, textArea); + $textArea.focus(); + return this.insertText(textArea, text, tag, blockTag, selected, wrap); +}; +gl.text.init = function(form) { + var self; + self = this; + return $('.js-md', form).off('click').on('click', function() { + var $this; + $this = $(this); + return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); + }); +}; +gl.text.removeListeners = function(form) { + return $('.js-md', form).off(); +}; +gl.text.humanize = function(string) { + return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); +}; +gl.text.pluralize = function(str, count) { + return str + (count > 1 || count === 0 ? 's' : ''); +}; +gl.text.truncate = function(string, maxLength) { + return string.substr(0, (maxLength - 3)) + '...'; +}; +gl.text.dasherize = function(str) { + return str.replace(/[_\s]+/g, '-'); +}; +gl.text.slugify = function(str) { + return str.trim().toLowerCase().latinise(); +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 09c4261b318..b9d2fc25c39 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,93 +1,90 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */ -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; +var base; +var w = window; +if (w.gl == null) { + w.gl = {}; +} +if ((base = w.gl).utils == null) { + base.utils = {}; +} +// Returns an array containing the value(s) of the +// of the key passed as an argument +w.gl.utils.getParameterValues = function(sParam) { + var i, sPageURL, sParameterName, sURLVariables, values; + sPageURL = decodeURIComponent(window.location.search.substring(1)); + sURLVariables = sPageURL.split('&'); + sParameterName = void 0; + values = []; + i = 0; + while (i < sURLVariables.length) { + sParameterName = sURLVariables[i].split('='); + if (sParameterName[0] === sParam) { + values.push(sParameterName[1].replace(/\+/g, ' ')); } - if ((base = w.gl).utils == null) { - base.utils = {}; + i += 1; + } + return values; +}; +// @param {Object} params - url keys and value to merge +// @param {String} url +w.gl.utils.mergeUrlParams = function(params, url) { + var lastChar, newUrl, paramName, paramValue, pattern; + newUrl = decodeURIComponent(url); + for (paramName in params) { + paramValue = params[paramName]; + pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); + if (paramValue == null) { + newUrl = newUrl.replace(pattern, ''); + } else if (url.search(pattern) !== -1) { + newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); + } else { + newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; } - // Returns an array containing the value(s) of the - // of the key passed as an argument - w.gl.utils.getParameterValues = function(sParam) { - var i, sPageURL, sParameterName, sURLVariables, values; - sPageURL = decodeURIComponent(window.location.search.substring(1)); - sURLVariables = sPageURL.split('&'); - sParameterName = void 0; - values = []; - i = 0; - while (i < sURLVariables.length) { - sParameterName = sURLVariables[i].split('='); - if (sParameterName[0] === sParam) { - values.push(sParameterName[1].replace(/\+/g, ' ')); - } - i += 1; + } + // Remove a trailing ampersand + lastChar = newUrl[newUrl.length - 1]; + if (lastChar === '&') { + newUrl = newUrl.slice(0, -1); + } + return newUrl; +}; +// removes parameter query string from url. returns the modified url +w.gl.utils.removeParamQueryString = function(url, param) { + var urlVariables, variables; + url = decodeURIComponent(url); + urlVariables = url.split('&'); + return ((function() { + var j, len, results; + results = []; + for (j = 0, len = urlVariables.length; j < len; j += 1) { + variables = urlVariables[j]; + if (variables.indexOf(param) === -1) { + results.push(variables); } - return values; - }; - // @param {Object} params - url keys and value to merge - // @param {String} url - w.gl.utils.mergeUrlParams = function(params, url) { - var lastChar, newUrl, paramName, paramValue, pattern; - newUrl = decodeURIComponent(url); - for (paramName in params) { - paramValue = params[paramName]; - pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); - if (paramValue == null) { - newUrl = newUrl.replace(pattern, ''); - } else if (url.search(pattern) !== -1) { - newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); - } else { - newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; - } - } - // Remove a trailing ampersand - lastChar = newUrl[newUrl.length - 1]; - if (lastChar === '&') { - newUrl = newUrl.slice(0, -1); - } - return newUrl; - }; - // removes parameter query string from url. returns the modified url - w.gl.utils.removeParamQueryString = function(url, param) { - var urlVariables, variables; - url = decodeURIComponent(url); - urlVariables = url.split('&'); - return ((function() { - var j, len, results; - results = []; - for (j = 0, len = urlVariables.length; j < len; j += 1) { - variables = urlVariables[j]; - if (variables.indexOf(param) === -1) { - results.push(variables); - } - } - return results; - })()).join('&'); - }; - w.gl.utils.removeParams = (params) => { - const url = new URL(window.location.href); - params.forEach((param) => { - url.search = w.gl.utils.removeParamQueryString(url.search, param); - }); - return url.href; - }; - w.gl.utils.getLocationHash = function(url) { - var hashIndex; - if (typeof url === 'undefined') { - // Note: We can't use window.location.hash here because it's - // not consistent across browsers - Firefox will pre-decode it - url = window.location.href; - } - hashIndex = url.indexOf('#'); - return hashIndex === -1 ? null : url.substring(hashIndex + 1); - }; + } + return results; + })()).join('&'); +}; +w.gl.utils.removeParams = (params) => { + const url = new URL(window.location.href); + params.forEach((param) => { + url.search = w.gl.utils.removeParamQueryString(url.search, param); + }); + return url.href; +}; +w.gl.utils.getLocationHash = function(url) { + var hashIndex; + if (typeof url === 'undefined') { + // Note: We can't use window.location.hash here because it's + // not consistent across browsers - Firefox will pre-decode it + url = window.location.href; + } + hashIndex = url.indexOf('#'); + return hashIndex === -1 ? null : url.substring(hashIndex + 1); +}; - w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); +w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); - w.gl.utils.visitUrl = (url) => { - document.location.href = url; - }; - })(window); -}).call(window); +w.gl.utils.visitUrl = (url) => { + document.location.href = url; +}; diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js index 9548a98f499..7b0997c6520 100644 --- a/app/assets/javascripts/merged_buttons.js +++ b/app/assets/javascripts/merged_buttons.js @@ -1,11 +1,13 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +import '~/lib/utils/url_utility'; +(function() { this.MergedButtons = (function() { function MergedButtons() { - this.removeSourceBranch = bind(this.removeSourceBranch, this); + this.removeSourceBranch = this.removeSourceBranch.bind(this); + this.removeBranchSuccess = this.removeBranchSuccess.bind(this); + this.removeBranchError = this.removeBranchError.bind(this); this.$removeBranchWidget = $('.remove_source_branch_widget'); this.$removeBranchProgress = $('.remove_source_branch_in_progress'); this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); @@ -22,7 +24,7 @@ MergedButtons.prototype.initEventListeners = function() { $(document).on('click', '.remove_source_branch', this.removeSourceBranch); $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess); - return $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); + $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); }; MergedButtons.prototype.removeSourceBranch = function() { @@ -31,7 +33,7 @@ }; MergedButtons.prototype.removeBranchSuccess = function() { - return location.reload(); + gl.utils.refreshCurrentPage(); }; MergedButtons.prototype.removeBranchError = function() { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 9a0f7a14e57..759401a7806 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -5,7 +5,7 @@ direction: rtl; @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) { - overflow-x: scroll; + overflow-x: auto; } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 12465d4a70b..11d44df4867 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -82,7 +82,7 @@ .input-token:last-child { flex: 1; -webkit-flex: 1; - max-width: initial; + max-width: inherit; } } @@ -246,17 +246,17 @@ } } -.filtered-search-history-dropdown-toggle-button { +.filtered-search-history-dropdown-wrapper { + position: static; display: flex; - align-items: center; + flex-direction: column; +} + +.filtered-search-history-dropdown-toggle-button { + flex: 1; width: auto; - height: 100%; - padding-top: 0; - padding-left: 0.75em; - padding-bottom: 0; - padding-right: 0.5em; + padding-right: 10px; - background-color: transparent; border-radius: 0; border-top: 0; border-left: 0; @@ -264,6 +264,7 @@ border-right: 1px solid $border-color; color: $gl-text-color-secondary; + line-height: 1; transition: color 0.1s linear; @@ -275,24 +276,21 @@ } .dropdown-toggle-text { + display: inline-block; color: inherit; .fa { + vertical-align: middle; color: inherit; } } .fa { - position: initial; + position: static; } } -.filtered-search-history-dropdown-wrapper { - position: initial; - flex-shrink: 0; -} - .filtered-search-history-dropdown { width: 40%; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index c241816788b..664539e93e1 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -158,6 +158,7 @@ li.task-list-item { list-style-type: none; position: relative; + min-height: 22px; padding-left: 28px; margin-left: 0 !important; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 712eb7caf33..20ef9a774e4 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -26,6 +26,7 @@ $gray-dark: darken($gray-light, $darken-dark-factor); $gray-darker: #eee; $gray-darkest: #c4c4c4; +$green-25: #f6fcf8; $green-50: #e4f5eb; $green-100: #bae6cc; $green-200: #8dd5aa; @@ -37,6 +38,7 @@ $green-700: #12753a; $green-800: #0e5a2d; $green-900: #0a4020; +$blue-25: #f6fafd; $blue-50: #e4eff9; $blue-100: #bcd7f1; $blue-200: #8fbce8; @@ -48,6 +50,7 @@ $blue-700: #17599c; $blue-800: #134a81; $blue-900: #0f3b66; +$orange-25: #fffcf8; $orange-50: #fff2e1; $orange-100: #fedfb3; $orange-200: #feca81; @@ -59,6 +62,7 @@ $orange-700: #c26700; $orange-800: #a35100; $orange-900: #853b00; +$red-25: #fef7f6; $red-50: #fbe7e4; $red-100: #f4c4bc; $red-200: #ed9d90; @@ -147,7 +151,7 @@ $gl-sidebar-padding: 22px; /* * Misc */ -$row-hover: lighten($blue-50, 2%); +$row-hover: $blue-25; $row-hover-border: $blue-100; $progress-color: #c0392b; $header-height: 50px; @@ -223,18 +227,18 @@ $gl-btn-active-gradient: inset 0 2px 3px $gl-btn-active-background; /* * Commit Diff Colors */ -$added: $green-300; -$deleted: $red-300; -$line-added: $green-50; -$line-added-dark: $green-100; -$line-removed: $red-50; -$line-removed-dark: $red-100; -$line-number-old: lighten($red-100, 5%); -$line-number-new: lighten($green-100, 5%); -$line-number-select: lighten($orange-100, 5%); -$line-target-blue: $blue-50; -$line-select-yellow: $orange-50; -$line-select-yellow-dark: $orange-100; +$added: #63c363; +$deleted: #f77; +$line-added: #ecfdf0; +$line-added-dark: #c7f0d2; +$line-removed: #fbe9eb; +$line-removed-dark: #fac5cd; +$line-number-old: #f9d7dc; +$line-number-new: #ddfbe6; +$line-number-select: #fbf2da; +$line-target-blue: #f6faff; +$line-select-yellow: #fcf8e7; +$line-select-yellow-dark: #f0e2bd; $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); $file-mode-changed: #777; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 03fddaeb163..144adbcdaef 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -39,7 +39,7 @@ overflow-y: hidden; font-size: 12px; - .fa-refresh { + .fa-spinner { font-size: 24px; margin-left: 20px; } @@ -219,7 +219,7 @@ font-size: 12px; position: relative; - .fa-refresh { + .fa-spinner { font-size: 24px; } @@ -366,7 +366,7 @@ background-color: $row-hover; } - .fa-refresh { + .fa-spinner { font-size: 13px; margin-left: 3px; } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 79da490675a..5b723f7c722 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -10,10 +10,14 @@ position: relative; &.event-inline { - .profile-icon { + .system-note-image { top: 20px; } + .user-avatar { + top: 14px; + } + .event-title, .event-item-timestamp { line-height: 40px; @@ -24,7 +28,7 @@ color: $gl-text-color; } - .profile-icon { + .system-note-image { position: absolute; left: 0; top: 14px; @@ -35,15 +39,18 @@ fill: $gl-text-color-secondary; } - &.open-icon svg { - fill: $green-300; + &.opened-icon, + &.created-icon { + svg { + fill: $green-300; + } } &.closed-icon svg { fill: $red-300; } - &.fork-icon svg { + &.accepted-icon svg { fill: $blue-300; } } @@ -128,8 +135,7 @@ li { &.commit { background: transparent; - padding: 3px; - padding-left: 0; + padding: 0; border: none; .commit-row-title { @@ -183,7 +189,7 @@ max-width: 100%; } - .profile-icon { + .system-note-image { display: none; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 94ea4c5c8c6..c78fb8ede79 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -18,12 +18,12 @@ ul.notes { float: left; svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; fill: $gray-darkest; position: absolute; - left: 30px; - top: 15px; + left: 0; + top: 16px; } } @@ -144,6 +144,10 @@ ul.notes { padding: 0; clear: both; + @media (min-width: $screen-sm-min) { + margin-left: 65px; + } + &.timeline-entry::after { clear: none; } @@ -172,6 +176,10 @@ ul.notes { .timeline-content { padding: 14px 10px; + + @media (min-width: $screen-sm-min) { + margin-left: 20px; + } } .note-header { @@ -619,7 +627,6 @@ ul.notes { } &:not(.is-disabled):hover, - &:not(.is-disabled):focus, &.is-active { color: $gl-text-green; @@ -633,6 +640,11 @@ ul.notes { height: 15px; width: 15px; } + + .loading { + margin: 0; + height: auto; + } } .discussion-next-btn { diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index b668a9331e7..1e41f980f31 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -10,7 +10,7 @@ class Projects::HooksController < Projects::ApplicationController @hook = @project.hooks.new(hook_params) @hook.save - unless @hook.valid? + unless @hook.valid? @hooks = @project.hooks.select(&:persisted?) flash[:alert] = @hook.errors.full_messages.join.html_safe end @@ -49,7 +49,7 @@ class Projects::HooksController < Projects::ApplicationController def hook_params params.require(:hook).permit( - :build_events, + :job_events, :pipeline_events, :enable_ssl_verification, :issues_events, diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 224b44db397..09dc8b38229 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -38,7 +38,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @collection_type = "MergeRequest" @merge_requests = merge_requests_collection @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.includes(merge_request_diff: :merge_request) + @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request) @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) if @merge_requests.out_of_range? && @merge_requests.total_pages != 0 diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 8109427a45f..3ca14dee33c 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -60,7 +60,7 @@ class RegistrationsController < Devise::RegistrationsController end def resource - @resource ||= Users::CreateService.new(current_user, sign_up_params).build + @resource ||= Users::BuildService.new(current_user, sign_up_params).execute end def devise_mapping diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index fb872a13f74..5f5c76d3722 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,4 +1,15 @@ module EventsHelper + ICON_NAMES_BY_EVENT_TYPE = { + 'pushed to' => 'icon_commit', + 'pushed new' => 'icon_commit', + 'created' => 'icon_status_open', + 'opened' => 'icon_status_open', + 'closed' => 'icon_status_closed', + 'accepted' => 'icon_code_fork', + 'commented on' => 'icon_comment_o', + 'deleted' => 'icon_trash_o' + }.freeze + def link_to_author(event) author = event.author @@ -183,4 +194,21 @@ module EventsHelper "event-inline" end end + + def icon_for_event(note) + icon_name = ICON_NAMES_BY_EVENT_TYPE[note] + custom_icon(icon_name) if icon_name + end + + def icon_for_profile_event(event) + if current_path?('users#show') + content_tag :div, class: "system-note-image #{event.action_name.parameterize}-icon" do + icon_for_event(event.action_name) + end + else + content_tag :div, class: 'system-note-image user-avatar' do + author_avatar(event, size: 32) + end + end + end end diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb index 68c09c922a6..d5e77c7e271 100644 --- a/app/helpers/javascript_helper.rb +++ b/app/helpers/javascript_helper.rb @@ -3,7 +3,8 @@ module JavascriptHelper javascript_include_tag asset_path(js) end - def page_specific_javascript_bundle_tag(js) - javascript_include_tag(*webpack_asset_paths(js)) + # deprecated; use webpack_bundle_tag directly instead + def page_specific_javascript_bundle_tag(bundle) + webpack_bundle_tag(bundle) end end diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb new file mode 100644 index 00000000000..6bacda9fe75 --- /dev/null +++ b/app/helpers/webpack_helper.rb @@ -0,0 +1,30 @@ +require 'webpack/rails/manifest' + +module WebpackHelper + def webpack_bundle_tag(bundle) + javascript_include_tag(*gitlab_webpack_asset_paths(bundle)) + end + + # override webpack-rails gem helper until changes can make it upstream + def gitlab_webpack_asset_paths(source, extension: nil) + return "" unless source.present? + + paths = Webpack::Rails::Manifest.asset_paths(source) + if extension + paths = paths.select { |p| p.ends_with? ".#{extension}" } + end + + # include full webpack-dev-server url for rspec tests running locally + if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled + host = Rails.configuration.webpack.dev_server.host + port = Rails.configuration.webpack.dev_server.port + protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http' + + paths.map! do |p| + "#{protocol}://#{host}:#{port}#{p}" + end + end + + paths + end +end diff --git a/app/models/commit.rb b/app/models/commit.rb index 5c452f78546..8b8b3f00202 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -326,14 +326,13 @@ class Commit end def raw_diffs(*args) - use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] - - if use_gitaly && !deltas_only - Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) - else - raw.diffs(*args) - end + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitaly/issues/178 is resolved + # if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + # Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + # else + raw.diffs(*args) + # end end def diffs(diff_options = nil) diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 67b1cace3eb..8ee42875670 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -2,9 +2,9 @@ module DiscussionOnDiff extend ActiveSupport::Concern - included do - NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + included do delegate :line_code, :original_line_code, :diff_file, diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 82f4182d59a..d0c94d3b694 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -20,7 +20,8 @@ class ContainerRepository < ActiveRecord::Base end def path - @path ||= [project.full_path, name].select(&:present?).join('/') + @path ||= [project.full_path, name] + .select(&:present?).join('/').downcase end def location diff --git a/app/models/label.rb b/app/models/label.rb index 568fa6d44f5..d8b0e250732 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -21,6 +21,8 @@ class Label < ActiveRecord::Base has_many :issues, through: :label_links, source: :target, source_type: 'Issue' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' + before_validation :strip_whitespace_from_title_and_color + validates :color, color: true, allow_blank: false # Don't allow ',' for label titles @@ -193,4 +195,8 @@ class Label < ActiveRecord::Base def sanitize_title(value) CGI.unescapeHTML(Sanitize.clean(value.to_s)) end + + def strip_whitespace_from_title_and_color + %w(color title).each { |attr| self[attr] = self[attr]&.strip } + end end diff --git a/app/models/note.rb b/app/models/note.rb index c85692c5aec..630d0adbece 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -96,6 +96,7 @@ class Note < ActiveRecord::Base before_validation :set_discussion_id, on: :create after_save :keep_around_commit, unless: :for_personal_snippet? after_save :expire_etag_cache + after_destroy :expire_etag_cache class << self def model_name diff --git a/app/models/repository.rb b/app/models/repository.rb index 526ab1e77a7..2b11ed6128e 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -963,13 +963,15 @@ class Repository end def is_ancestor?(ancestor_id, descendant_id) - Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled| - if is_enabled - raw_repository.is_ancestor?(ancestor_id, descendant_id) - else - merge_base_commit(ancestor_id, descendant_id) == ancestor_id - end - end + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitlab-ce/issues/30586 is resolved + # Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled| + # if is_enabled + # raw_repository.is_ancestor?(ancestor_id, descendant_id) + # else + merge_base_commit(ancestor_id, descendant_id) == ancestor_id + # end + # end end def empty_repo? diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index cb58c115d54..87398303c68 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -28,6 +28,7 @@ class GroupPolicy < BasePolicy can! :admin_namespace can! :admin_group_member can! :change_visibility_level + can! :create_subgroup if @user.can_create_group end if globally_viewable && @subject.request_access_enabled && !member diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index fbdaa455651..7828c5806b0 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -58,6 +58,9 @@ module Projects fail(error: @project.errors.full_messages.join(', ')) end @project + rescue ActiveRecord::RecordInvalid => e + message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " + fail(error: message) rescue => e fail(error: e.message) end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb new file mode 100644 index 00000000000..9a0a5a12f91 --- /dev/null +++ b/app/services/users/build_service.rb @@ -0,0 +1,100 @@ +module Users + # Service for building a new user. + class BuildService < BaseService + def initialize(current_user, params = {}) + @current_user = current_user + @params = params.dup + end + + def execute + raise Gitlab::Access::AccessDeniedError unless can_create_user? + + user = User.new(build_user_params) + + if current_user&.admin? + if params[:reset_password] + user.generate_reset_token + params[:force_random_password] = true + end + + if params[:force_random_password] + random_password = Devise.friendly_token.first(Devise.password_length.min) + user.password = user.password_confirmation = random_password + end + end + + identity_attrs = params.slice(:extern_uid, :provider) + + if identity_attrs.any? + user.identities.build(identity_attrs) + end + + user + end + + private + + def can_create_user? + (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin? + end + + # Allowed params for creating a user (admins only) + def admin_create_params + [ + :access_level, + :admin, + :avatar, + :bio, + :can_create_group, + :color_scheme_id, + :email, + :external, + :force_random_password, + :hide_no_password, + :hide_no_ssh_key, + :key_id, + :linkedin, + :name, + :password, + :password_automatically_set, + :password_expires_at, + :projects_limit, + :remember_me, + :skip_confirmation, + :skype, + :theme_id, + :twitter, + :username, + :website_url + ] + end + + # Allowed params for user signup + def signup_params + [ + :email, + :email_confirmation, + :password_automatically_set, + :name, + :password, + :username + ] + end + + def build_user_params + if current_user&.admin? + user_params = params.slice(*admin_create_params) + user_params[:created_by_id] = current_user&.id + + if params[:reset_password] + user_params.merge!(force_random_password: true, password_expires_at: nil) + end + else + user_params = params.slice(*signup_params) + user_params[:skip_confirmation] = !current_application_settings.send_user_confirmation_email + end + + user_params + end + end +end diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index 93ca7b1141a..a2105d31f71 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -6,34 +6,10 @@ module Users @params = params.dup end - def build - raise Gitlab::Access::AccessDeniedError unless can_create_user? - - user = User.new(build_user_params) - - if current_user&.admin? - if params[:reset_password] - @reset_token = user.generate_reset_token - params[:force_random_password] = true - end - - if params[:force_random_password] - random_password = Devise.friendly_token.first(Devise.password_length.min) - user.password = user.password_confirmation = random_password - end - end - - identity_attrs = params.slice(:extern_uid, :provider) - - if identity_attrs.any? - user.identities.build(identity_attrs) - end - - user - end - def execute - user = build + user = Users::BuildService.new(current_user, params).execute + + @reset_token = user.generate_reset_token if user.recently_sent_password_reset? if user.save log_info("User \"#{user.name}\" (#{user.email}) was created") @@ -43,70 +19,5 @@ module Users user end - - private - - def can_create_user? - (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin? - end - - # Allowed params for creating a user (admins only) - def admin_create_params - [ - :access_level, - :admin, - :avatar, - :bio, - :can_create_group, - :color_scheme_id, - :email, - :external, - :force_random_password, - :password_automatically_set, - :hide_no_password, - :hide_no_ssh_key, - :key_id, - :linkedin, - :name, - :password, - :password_expires_at, - :projects_limit, - :remember_me, - :skip_confirmation, - :skype, - :theme_id, - :twitter, - :username, - :website_url - ] - end - - # Allowed params for user signup - def signup_params - [ - :email, - :email_confirmation, - :password_automatically_set, - :name, - :password, - :username - ] - end - - def build_user_params - if current_user&.admin? - user_params = params.slice(*admin_create_params) - user_params[:created_by_id] = current_user&.id - - if params[:reset_password] - user_params.merge!(force_random_password: true, password_expires_at: nil) - end - else - user_params = params.slice(*signup_params) - user_params[:skip_confirmation] = !current_application_settings.send_user_confirmation_email - end - - user_params - end end end diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index e79303240f0..6a208d76a38 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -13,7 +13,7 @@ = button_to reset_health_check_token_admin_application_settings_path, method: :put, class: 'btn btn-default', data: { confirm: 'Are you sure you want to reset the health check token?' } do - = icon('refresh') + = icon('spinner') Reset health check access token %p.light Health information can be retrieved as plain text, JSON, or XML using: diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 7d26864d0f3..f118804cace 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -21,7 +21,7 @@ = button_to reset_runners_token_admin_application_settings_path, method: :put, class: 'btn btn-default', data: { confirm: 'Are you sure you want to reset registration token?' } do - = icon('refresh') + = icon('spinner') Reset runners registration token .bs-callout diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml index 6a5986f496a..50132572096 100644 --- a/app/views/admin/services/index.html.haml +++ b/app/views/admin/services/index.html.haml @@ -13,7 +13,7 @@ - @services.sort_by(&:title).each do |service| %tr %td - = icon("copy", class: 'clgray') + = boolean_to_icon service.activated? %td = link_to edit_admin_application_settings_service_path(service.id) do %strong= service.title diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index af97e9588a5..01e72862114 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,13 +1,4 @@ -- if event.target - - if event.action_name == "opened" - .profile-icon.open-icon - = custom_icon("icon_status_open") - - elsif event.action_name == "closed" - .profile-icon.closed-icon - = custom_icon("icon_status_closed") - - else - .profile-icon.fork-icon - = custom_icon("icon_code_fork") += icon_for_profile_event(event) .event-title %span.author_name= link_to_author event diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index fee85c94277..d8e59be57bb 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -1,5 +1,4 @@ -.profile-icon.open-icon - = custom_icon("icon_status_open") += icon_for_profile_event(event) .event-title %span.author_name= link_to_author event diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 83709f5e4d0..df4b9562215 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -1,5 +1,4 @@ -.profile-icon - = custom_icon("icon_comment_o") += icon_for_profile_event(event) .event-title %span.author_name= link_to_author event diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index efdc8764acf..c0943100ae3 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -1,10 +1,6 @@ - project = event.project -.profile-icon - - if event.action_name == "deleted" - = custom_icon("trash_o") - - else - = custom_icon("icon_commit") += icon_for_profile_event(event) .event-title %span.author_name= link_to_author event diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml index be809083139..8f0724c0677 100644 --- a/app/views/groups/subgroups.html.haml +++ b/app/views/groups/subgroups.html.haml @@ -9,7 +9,7 @@ .nav-controls = form_tag request.path, method: :get do |f| = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false - - if can? current_user, :admin_group, @group + - if can?(current_user, :create_subgroup, @group) = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do New Subgroup diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 207f80bedfe..615dd56afbd 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -252,7 +252,7 @@ = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-title - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input @@ -291,7 +291,7 @@ = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable.is-loading .dropdown-title - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input @@ -335,7 +335,7 @@ = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user .dropdown-title - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input @@ -362,7 +362,7 @@ .dropdown-title %button.dropdown-title-button.dropdown-menu-back{ aria: { label: "Go back" } } = icon('arrow-left') - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index a611481a0a4..19473b6ab27 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,9 +28,9 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" - = javascript_include_tag(*webpack_asset_paths("runtime")) - = javascript_include_tag(*webpack_asset_paths("common")) - = javascript_include_tag(*webpack_asset_paths("main")) + = webpack_bundle_tag "runtime" + = webpack_bundle_tag "common" + = webpack_bundle_tag "main" - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 299dace3406..e34cddeb3e2 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -23,7 +23,7 @@ Registry - if project_nav_tab? :issues - = nav_link(controller: [:issues, :labels, :milestones, :boards]) do + = nav_link(controller: @project.default_issues_tracker? ? [:issues, :labels, :milestones, :boards] : :issues) do = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do %span Issues @@ -31,7 +31,7 @@ %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :merge_requests - = nav_link(controller: :merge_requests) do + = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do %span Merge Requests diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index f4a66398c85..c4159ce1a36 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -136,7 +136,7 @@ - else = build.id - if build.retried? - %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } + %i.fa.fa-spinner.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } :javascript new Sidebar(); diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 4700b7a9a45..2c3fd1fcd4d 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -36,7 +36,7 @@ = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') - if retried - = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried') + = icon('spinner', class: 'text-warning has-tooltip', title: 'Job was retried') .label-container - if job.tags.any? diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml index a80a07b52e6..7f0059cdcda 100644 --- a/app/views/projects/labels/edit.html.haml +++ b/app/views/projects/labels/edit.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Edit", @label.name, "Labels" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 29f861c09c6..fc72c4fb635 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -1,7 +1,7 @@ - @no_container = true - page_title "Labels" - hide_class = '' -= render "projects/issues/head" += render "shared/mr_head" - if @labels.exists? || @prioritized_labels.exists? %div{ class: container_class } diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml index f0d9be744d1..8f6c085a361 100644 --- a/app/views/projects/labels/new.html.haml +++ b/app/views/projects/labels/new.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "New Label" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/merge_requests/_head.html.haml b/app/views/projects/merge_requests/_head.html.haml new file mode 100644 index 00000000000..b7f73fe5339 --- /dev/null +++ b/app/views/projects/merge_requests/_head.html.haml @@ -0,0 +1,21 @@ += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + = nav_link(controller: :merge_requests) do + = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do + %span + List + + - if project_nav_tab? :labels + = nav_link(controller: :labels) do + = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do + %span + Labels + + - if project_nav_tab? :milestones + = nav_link(controller: :milestones) do + = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do + %span + Milestones diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 64f17ab34b1..6bf0035e051 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -2,6 +2,9 @@ - @bulk_edit = can?(current_user, :admin_merge_request, @project) - page_title "Merge Requests" +- unless @project.default_issues_tracker? + = content_for :sub_nav do + = render "projects/merge_requests/head" = render 'projects/last_push' - content_for :page_specific_javascripts do diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index 55b0b837c6d..e57a76dbfd2 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Edit", @milestone.title, "Milestones" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 8e85b2e8a20..e1096bd1d67 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title 'Milestones' -= render 'projects/issues/head' += render "shared/mr_head" %div{ class: container_class } .top-area diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml index cda093ade81..586eb909afa 100644 --- a/app/views/projects/milestones/new.html.haml +++ b/app/views/projects/milestones/new.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "New Milestone" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 8b62b156853..a173117984d 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -1,7 +1,7 @@ - @no_container = true - page_title @milestone.title, "Milestones" - page_description @milestone.description -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } .detail-page-header.milestone-page-header diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index c12c05eeb73..1f021ad77e5 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -52,11 +52,10 @@ ":aria-label" => "buttonText", "@click" => "resolve", ":title" => "buttonText", - "v-show" => "!loading", ":ref" => "'button'" } - = icon("spin spinner", "v-show" => "loading") - = render "shared/icons/icon_status_success.svg" + = icon("spin spinner", "v-show" => "loading", class: 'loading') + %div{ 'v-show' => '!loading' }= render "shared/icons/icon_status_success.svg" - if current_user - if note.emoji_awardable? diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 54b5ae2402e..1c7c73be933 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -2,7 +2,7 @@ = f.label :import_url, class: 'control-label' do %span Git repository URL .col-sm-10 - = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', disabled: true + = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git' .well.prepend-top-20 %ul diff --git a/app/views/shared/_mr_head.html.haml b/app/views/shared/_mr_head.html.haml new file mode 100644 index 00000000000..4211ec6351d --- /dev/null +++ b/app/views/shared/_mr_head.html.haml @@ -0,0 +1,4 @@ +- if @project.default_issues_tracker? + = render "projects/issues/head" +- else + = render "projects/merge_requests/head" diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 7a7e3d46796..c229d18903f 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -16,6 +16,8 @@ Also, issues are searchable and filterable. - if project_select_button = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue' + = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' - else - %h4 There are no issues to show. - = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' + .text-center + %h4 There are no issues to show. + = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' diff --git a/app/views/shared/icons/_icon_merged.svg b/app/views/shared/icons/_icon_merged.svg index d8f96558bea..43d591daefa 100644 --- a/app/views/shared/icons/_icon_merged.svg +++ b/app/views/shared/icons/_icon_merged.svg @@ -1 +1 @@ -<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><path d="M9.427 6.523a.932.932 0 0 0-.808.489v-.01c-.49-.01-1.059-.172-1.46-.489-.35-.278-.7-.772-.882-1.17a.964.964 0 0 0 .35-.744.943.943 0 0 0-.934-.959c-.518 0-.933.432-.933.964 0 .35.191.662.467.825v3.147a.97.97 0 0 0-.467.825c0 .532.415.959.933.959a.943.943 0 0 0 .934-.96.965.965 0 0 0-.467-.824V6.844c.313.336.672.61 1.073.81.402.202.948.303 1.386.308v-.01c.168.293.467.49.808.49a.943.943 0 0 0 .933-.96.943.943 0 0 0-.933-.96z"/></svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m2 3c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m.761.85c.154 2.556 1.987 4.692 4.45 5.255.328-.655 1.01-1.105 1.789-1.105 1.105 0 2 .895 2 2 0 1.105-.895 2-2 2-.89 0-1.645-.582-1.904-1.386-1.916-.376-3.548-1.5-4.596-3.044v4.493c.863.222 1.5 1.01 1.5 1.937 0 1.105-.895 2-2 2-1.105 0-2-.895-2-2 0-.74.402-1.387 1-1.732v-8.535c-.598-.346-1-.992-1-1.732 0-1.105.895-2 2-2 1.105 0 2 .895 2 2 0 .835-.512 1.551-1.239 1.85m6.239 7.15c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m-7 4c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1" transform="translate(3)"/></svg> diff --git a/app/views/shared/icons/_trash_o.svg b/app/views/shared/icons/_icon_trash_o.svg index 0d7a91ab536..0d7a91ab536 100644 --- a/app/views/shared/icons/_trash_o.svg +++ b/app/views/shared/icons/_icon_trash_o.svg diff --git a/app/workers/build_coverage_worker.rb b/app/workers/build_coverage_worker.rb index def0ab1dde1..f7ae996bb17 100644 --- a/app/workers/build_coverage_worker.rb +++ b/app/workers/build_coverage_worker.rb @@ -3,7 +3,6 @@ class BuildCoverageWorker include BuildQueue def perform(build_id) - Ci::Build.find_by(id: build_id) - .try(:update_coverage) + Ci::Build.find_by(id: build_id)&.update_coverage end end diff --git a/changelogs/unreleased/2120-issues-search-bar-not-picking-up.yml b/changelogs/unreleased/2120-issues-search-bar-not-picking-up.yml new file mode 100644 index 00000000000..706609b7baf --- /dev/null +++ b/changelogs/unreleased/2120-issues-search-bar-not-picking-up.yml @@ -0,0 +1,4 @@ +--- +title: Fix filtered search input width for IE +merge_request: +author: diff --git a/changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml b/changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml new file mode 100644 index 00000000000..ad7c011933f --- /dev/null +++ b/changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml @@ -0,0 +1,4 @@ +--- +title: Update all instances of the old loading icon +merge_request: 10490 +author: Andrew Torres diff --git a/changelogs/unreleased/27729-improve-webpack-dev-environment.yml b/changelogs/unreleased/27729-improve-webpack-dev-environment.yml new file mode 100644 index 00000000000..d04ea70ab1c --- /dev/null +++ b/changelogs/unreleased/27729-improve-webpack-dev-environment.yml @@ -0,0 +1,4 @@ +--- +title: Add webpack_bundle_tag helper to improve non-localhost GDK configurations +merge_request: 10604 +author: diff --git a/changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml b/changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml new file mode 100644 index 00000000000..0ebb9d57611 --- /dev/null +++ b/changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml @@ -0,0 +1,4 @@ +--- +title: Turns true value and false value database methods from instance to class methods +merge_request: 10583 +author: diff --git a/changelogs/unreleased/30349-create-users-build-service.yml b/changelogs/unreleased/30349-create-users-build-service.yml new file mode 100644 index 00000000000..49b571f5646 --- /dev/null +++ b/changelogs/unreleased/30349-create-users-build-service.yml @@ -0,0 +1,4 @@ +--- +title: Implement Users::BuildService +merge_request: 30349 +author: George Andrinopoulos diff --git a/changelogs/unreleased/30457-expire-note-destroy.yml b/changelogs/unreleased/30457-expire-note-destroy.yml new file mode 100644 index 00000000000..f5c89da68a9 --- /dev/null +++ b/changelogs/unreleased/30457-expire-note-destroy.yml @@ -0,0 +1,4 @@ +--- +title: Fix issue's note cache expiration after delete +merge_request: +author: mhasbini diff --git a/changelogs/unreleased/30779-show-mr-subnav-issue-tracker.yml b/changelogs/unreleased/30779-show-mr-subnav-issue-tracker.yml new file mode 100644 index 00000000000..59f8942911c --- /dev/null +++ b/changelogs/unreleased/30779-show-mr-subnav-issue-tracker.yml @@ -0,0 +1,4 @@ +--- +title: Show sub-nav under Merge Requests when issue tracker is non-default. +merge_request: 10658 +author: diff --git a/changelogs/unreleased/bb_save_trace.yml b/changelogs/unreleased/bb_save_trace.yml new file mode 100644 index 00000000000..6ff31f4f111 --- /dev/null +++ b/changelogs/unreleased/bb_save_trace.yml @@ -0,0 +1,5 @@ +--- +title: "[BB Importer] Save the error trace and the whole raw document to debug problems + easier" +merge_request: +author: diff --git a/changelogs/unreleased/empty-task-list-alignment.yml b/changelogs/unreleased/empty-task-list-alignment.yml new file mode 100644 index 00000000000..ca04e1cab5a --- /dev/null +++ b/changelogs/unreleased/empty-task-list-alignment.yml @@ -0,0 +1,4 @@ +--- +title: Fixed alignment of empty task list items +merge_request: +author: diff --git a/changelogs/unreleased/fix-missing-capitalisation-buttons.yml b/changelogs/unreleased/fix-missing-capitalisation-buttons.yml new file mode 100644 index 00000000000..b2c40483475 --- /dev/null +++ b/changelogs/unreleased/fix-missing-capitalisation-buttons.yml @@ -0,0 +1,4 @@ +--- +title: Fix missing capitalisation on views +merge_request: +author: diff --git a/changelogs/unreleased/fix-preloading-merge_request_diff.yml b/changelogs/unreleased/fix-preloading-merge_request_diff.yml new file mode 100644 index 00000000000..d38b6b0a707 --- /dev/null +++ b/changelogs/unreleased/fix-preloading-merge_request_diff.yml @@ -0,0 +1,4 @@ +--- +title: Fix bad query for PostgreSQL showing merge requests list +merge_request: 10666 +author: diff --git a/changelogs/unreleased/fix-trace-seeking.yml b/changelogs/unreleased/fix-trace-seeking.yml new file mode 100644 index 00000000000..b753df4bb43 --- /dev/null +++ b/changelogs/unreleased/fix-trace-seeking.yml @@ -0,0 +1,4 @@ +--- +title: Fix invalid encoding when showing some traces +merge_request: 10681 +author: diff --git a/changelogs/unreleased/fix_spaces_in_label_title.yml b/changelogs/unreleased/fix_spaces_in_label_title.yml new file mode 100644 index 00000000000..51f07438edb --- /dev/null +++ b/changelogs/unreleased/fix_spaces_in_label_title.yml @@ -0,0 +1,4 @@ +--- +title: Remove heading and trailing spaces from label's color and title +merge_request: 10603 +author: blackst0ne diff --git a/changelogs/unreleased/issues-empty-state-not-centered.yml b/changelogs/unreleased/issues-empty-state-not-centered.yml new file mode 100644 index 00000000000..883125e28b1 --- /dev/null +++ b/changelogs/unreleased/issues-empty-state-not-centered.yml @@ -0,0 +1,4 @@ +--- +title: Centered issues empty state +merge_request: +author: diff --git a/changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml b/changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml new file mode 100644 index 00000000000..3b9284258cb --- /dev/null +++ b/changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml @@ -0,0 +1,4 @@ +--- +title: "Make the `gitlab:gitlab_shell:check` task check that the repositories storage path are owned by the `root` group" +merge_request: +author: diff --git a/changelogs/unreleased/pms-lighter-colors.yml b/changelogs/unreleased/pms-lighter-colors.yml new file mode 100644 index 00000000000..958d4bc0ac0 --- /dev/null +++ b/changelogs/unreleased/pms-lighter-colors.yml @@ -0,0 +1,4 @@ +--- +title: Add lighter colors and fix existing light colors +merge_request: 10690 +author: diff --git a/changelogs/unreleased/reset-new-branch-button.yml b/changelogs/unreleased/reset-new-branch-button.yml new file mode 100644 index 00000000000..318ee46298f --- /dev/null +++ b/changelogs/unreleased/reset-new-branch-button.yml @@ -0,0 +1,4 @@ +--- +title: Reset New branch button when issue state changes +merge_request: 5962 +author: winniehell diff --git a/changelogs/unreleased/siemens-gitlab-ce-fix-subgroup-hide-button.yml b/changelogs/unreleased/siemens-gitlab-ce-fix-subgroup-hide-button.yml new file mode 100644 index 00000000000..716311c7582 --- /dev/null +++ b/changelogs/unreleased/siemens-gitlab-ce-fix-subgroup-hide-button.yml @@ -0,0 +1,4 @@ +--- +title: Hide new subgroup button if user has no permission to create one +merge_request: 10627 +author: diff --git a/changelogs/unreleased/user-activity-scroll-bar.yml b/changelogs/unreleased/user-activity-scroll-bar.yml new file mode 100644 index 00000000000..97cccee42cb --- /dev/null +++ b/changelogs/unreleased/user-activity-scroll-bar.yml @@ -0,0 +1,4 @@ +--- +title: Fix preemptive scroll bar on user activity calendar. +merge_request: !10636 +author: diff --git a/changelogs/unreleased/zj-api-fix-build-events.yml b/changelogs/unreleased/zj-api-fix-build-events.yml new file mode 100644 index 00000000000..7700d8dcd22 --- /dev/null +++ b/changelogs/unreleased/zj-api-fix-build-events.yml @@ -0,0 +1,4 @@ +--- +title: "Bugfix: POST /projects/:id/hooks and PUT /projects/:id/hook/:hook_id no longer ignore the the job_events param in the V4 API" +merge_request: 10586 +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 3c70f35b9d0..06c9f734c2a 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -579,9 +579,9 @@ test: storages: default: path: tmp/tests/repositories/ - gitaly_address: unix:<%= Rails.root.join('tmp/sockets/private/gitaly.socket') %> + gitaly_address: unix:tmp/tests/gitaly/gitaly.socket gitaly: - enabled: false + enabled: true backup: path: tmp/tests/backups gitlab_shell: diff --git a/config/webpack.config.js b/config/webpack.config.js index 0fea3f8222b..ffb16190093 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -11,6 +11,7 @@ var WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeMod var ROOT_PATH = path.resolve(__dirname, '..'); var IS_PRODUCTION = process.env.NODE_ENV === 'production'; var IS_DEV_SERVER = process.argv[1].indexOf('webpack-dev-server') !== -1; +var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; @@ -182,12 +183,13 @@ if (IS_PRODUCTION) { if (IS_DEV_SERVER) { config.devtool = 'cheap-module-eval-source-map'; config.devServer = { + host: DEV_SERVER_HOST, port: DEV_SERVER_PORT, headers: { 'Access-Control-Allow-Origin': '*' }, stats: 'errors-only', inline: DEV_SERVER_LIVERELOAD }; - config.output.publicPath = '//localhost:' + DEV_SERVER_PORT + config.output.publicPath; + config.output.publicPath = '//' + DEV_SERVER_HOST + ':' + DEV_SERVER_PORT + config.output.publicPath; config.plugins.push( // watch node_modules for changes if we encounter a missing module compile error new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules')) diff --git a/db/migrate/20130218141258_convert_closed_to_state_in_issue.rb b/db/migrate/20130218141258_convert_closed_to_state_in_issue.rb index 94c0a6845d5..67a0d3b53eb 100644 --- a/db/migrate/20130218141258_convert_closed_to_state_in_issue.rb +++ b/db/migrate/20130218141258_convert_closed_to_state_in_issue.rb @@ -1,6 +1,6 @@ # rubocop:disable all class ConvertClosedToStateInIssue < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up execute "UPDATE #{table_name} SET state = 'closed' WHERE closed = #{true_value}" diff --git a/db/migrate/20130218141327_convert_closed_to_state_in_merge_request.rb b/db/migrate/20130218141327_convert_closed_to_state_in_merge_request.rb index 64a9c761352..307fc6a023d 100644 --- a/db/migrate/20130218141327_convert_closed_to_state_in_merge_request.rb +++ b/db/migrate/20130218141327_convert_closed_to_state_in_merge_request.rb @@ -1,6 +1,6 @@ # rubocop:disable all class ConvertClosedToStateInMergeRequest < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up execute "UPDATE #{table_name} SET state = 'merged' WHERE closed = #{true_value} AND merged = #{true_value}" diff --git a/db/migrate/20130218141344_convert_closed_to_state_in_milestone.rb b/db/migrate/20130218141344_convert_closed_to_state_in_milestone.rb index 41508c2dc95..d12703cf3b2 100644 --- a/db/migrate/20130218141344_convert_closed_to_state_in_milestone.rb +++ b/db/migrate/20130218141344_convert_closed_to_state_in_milestone.rb @@ -1,6 +1,6 @@ # rubocop:disable all class ConvertClosedToStateInMilestone < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up execute "UPDATE #{table_name} SET state = 'closed' WHERE closed = #{true_value}" diff --git a/db/migrate/20130315124931_user_color_scheme.rb b/db/migrate/20130315124931_user_color_scheme.rb index 06e28a49d9d..09af928fde7 100644 --- a/db/migrate/20130315124931_user_color_scheme.rb +++ b/db/migrate/20130315124931_user_color_scheme.rb @@ -1,6 +1,6 @@ # rubocop:disable all class UserColorScheme < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up add_column :users, :color_scheme_id, :integer, null: false, default: 1 diff --git a/db/migrate/20131112220935_add_visibility_level_to_projects.rb b/db/migrate/20131112220935_add_visibility_level_to_projects.rb index 5efc17b228e..86d73753adc 100644 --- a/db/migrate/20131112220935_add_visibility_level_to_projects.rb +++ b/db/migrate/20131112220935_add_visibility_level_to_projects.rb @@ -1,6 +1,6 @@ # rubocop:disable all class AddVisibilityLevelToProjects < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def self.up add_column :projects, :visibility_level, :integer, :default => 0, :null => false diff --git a/db/migrate/20140313092127_migrate_already_imported_projects.rb b/db/migrate/20140313092127_migrate_already_imported_projects.rb index f2e91fe1b40..0afc26b8764 100644 --- a/db/migrate/20140313092127_migrate_already_imported_projects.rb +++ b/db/migrate/20140313092127_migrate_already_imported_projects.rb @@ -1,6 +1,6 @@ # rubocop:disable all class MigrateAlreadyImportedProjects < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up execute("UPDATE projects SET import_status = 'finished' WHERE imported = #{true_value}") diff --git a/db/migrate/20141007100818_add_visibility_level_to_snippet.rb b/db/migrate/20141007100818_add_visibility_level_to_snippet.rb index 688d8578478..0c14f75c154 100644 --- a/db/migrate/20141007100818_add_visibility_level_to_snippet.rb +++ b/db/migrate/20141007100818_add_visibility_level_to_snippet.rb @@ -1,6 +1,6 @@ # rubocop:disable all class AddVisibilityLevelToSnippet < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up add_column :snippets, :visibility_level, :integer, :default => 0, :null => false diff --git a/db/migrate/20151209144329_migrate_ci_web_hooks.rb b/db/migrate/20151209144329_migrate_ci_web_hooks.rb index cb1e556623a..62a6d334f04 100644 --- a/db/migrate/20151209144329_migrate_ci_web_hooks.rb +++ b/db/migrate/20151209144329_migrate_ci_web_hooks.rb @@ -1,6 +1,6 @@ # rubocop:disable all class MigrateCiWebHooks < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up execute( diff --git a/db/migrate/20151209145909_migrate_ci_emails.rb b/db/migrate/20151209145909_migrate_ci_emails.rb index 6b7a106814d..5de7b205fb1 100644 --- a/db/migrate/20151209145909_migrate_ci_emails.rb +++ b/db/migrate/20151209145909_migrate_ci_emails.rb @@ -1,6 +1,6 @@ # rubocop:disable all class MigrateCiEmails < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up # This inserts a new service: BuildsEmailService diff --git a/db/migrate/20151210125232_migrate_ci_slack_service.rb b/db/migrate/20151210125232_migrate_ci_slack_service.rb index 633d5148d97..fff130b7b10 100644 --- a/db/migrate/20151210125232_migrate_ci_slack_service.rb +++ b/db/migrate/20151210125232_migrate_ci_slack_service.rb @@ -1,6 +1,6 @@ # rubocop:disable all class MigrateCiSlackService < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up properties_query = 'SELECT properties FROM ci_services ' \ diff --git a/db/migrate/20151210125927_migrate_ci_hip_chat_service.rb b/db/migrate/20151210125927_migrate_ci_hip_chat_service.rb index dae084ce180..824f6f84195 100644 --- a/db/migrate/20151210125927_migrate_ci_hip_chat_service.rb +++ b/db/migrate/20151210125927_migrate_ci_hip_chat_service.rb @@ -1,6 +1,6 @@ # rubocop:disable all class MigrateCiHipChatService < ActiveRecord::Migration - include Gitlab::Database + include Gitlab::Database::MigrationHelpers def up # From properties strip `hipchat_` key diff --git a/db/post_migrate/20170301205640_migrate_build_events_to_pipeline_events.rb b/db/post_migrate/20170301205640_migrate_build_events_to_pipeline_events.rb index 2dd14ee5a78..04bf89c9687 100644 --- a/db/post_migrate/20170301205640_migrate_build_events_to_pipeline_events.rb +++ b/db/post_migrate/20170301205640_migrate_build_events_to_pipeline_events.rb @@ -1,6 +1,5 @@ class MigrateBuildEventsToPipelineEvents < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers - include Gitlab::Database DOWNTIME = false diff --git a/doc/ci/img/pipelines.png b/doc/ci/img/pipelines.png Binary files differindex 5937e9d99c8..a604fcb2587 100644 --- a/doc/ci/img/pipelines.png +++ b/doc/ci/img/pipelines.png diff --git a/doc/development/fe_guide/performance.md b/doc/development/fe_guide/performance.md index e74eb729515..2ddcbe13afa 100644 --- a/doc/development/fe_guide/performance.md +++ b/doc/development/fe_guide/performance.md @@ -48,8 +48,8 @@ Steps to split page-specific JavaScript from the main `main.js`: ```haml - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_chart') - = page_specific_javascript_bundle_tag('graphs') + = webpack_bundle_tag 'lib_chart' + = webpack_bundle_tag 'graphs' ``` The above loads `chart.js` and `graphs_bundle.js` for this page only. `chart.js` diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index 8d3513d3566..a4631fd0073 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -13,10 +13,19 @@ for more information on general testing practices at GitLab. ## Karma test suite GitLab uses the [Karma][karma] test runner with [Jasmine][jasmine] as its test -framework for our JavaScript unit tests. For tests that rely on DOM +framework for our JavaScript unit tests. For tests that rely on DOM manipulation we use fixtures which are pre-compiled from HAML source files and served during testing by the [jasmine-jquery][jasmine-jquery] plugin. +JavaScript tests live in `spec/javascripts/`, matching the folder structure +of `app/assets/javascripts/`: `app/assets/javascripts/behaviors/autosize.js` +has a corresponding `spec/javascripts/behaviors/autosize_spec.js` file. + +Keep in mind that in a CI environment, these tests are run in a headless +browser and you will not have access to certain APIs, such as +[`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification), +which will have to be stubbed. + ### Running frontend tests `rake karma` runs the frontend-only (JavaScript) tests. @@ -80,24 +89,23 @@ If an integration test depends on JavaScript to run correctly, you need to make sure the spec is configured to enable JavaScript when the tests are run. If you don't do this you'll see vague error messages from the spec runner. -To enable a JavaScript driver in an `rspec` test, add `js: true` to the +To enable a JavaScript driver in an `rspec` test, add `:js` to the individual spec or the context block containing multiple specs that need JavaScript enabled: ```ruby - # For one spec -it 'presents information about abuse report', js: true do - # assertions... +it 'presents information about abuse report', :js do + # assertions... end -describe "Admin::AbuseReports", js: true do - it 'presents information about abuse report' do - # assertions... - end - it 'shows buttons for adding to abuse report' do - # assertions... - end +describe "Admin::AbuseReports", :js do + it 'presents information about abuse report' do + # assertions... + end + it 'shows buttons for adding to abuse report' do + # assertions... + end end ``` @@ -113,13 +121,12 @@ file for the failing spec, add the `@javascript` flag above the Scenario: ``` @javascript Scenario: Developer can approve merge request - Given I am a "Shop" developer - And I visit project "Shop" merge requests page - And merge request 'Bug NS-04' must be approved - And I click link "Bug NS-04" - When I click link "Approve" - Then I should see approved merge request "Bug NS-04" - + Given I am a "Shop" developer + And I visit project "Shop" merge requests page + And merge request 'Bug NS-04' must be approved + And I click link "Bug NS-04" + When I click link "Approve" + Then I should see approved merge request "Bug NS-04" ``` [capybara]: http://teamcapybara.github.io/capybara/ diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 587922d0136..3e8b709c18f 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -4,28 +4,53 @@ When writing migrations for GitLab, you have to take into account that these will be ran by hundreds of thousands of organizations of all sizes, some with many years of data in their database. -In addition, having to take a server offline for a an upgrade small or big is -a big burden for most organizations. For this reason it is important that your -migrations are written carefully, can be applied online and adhere to the style guide below. +In addition, having to take a server offline for a a upgrade small or big is a +big burden for most organizations. For this reason it is important that your +migrations are written carefully, can be applied online and adhere to the style +guide below. -Migrations should not require GitLab installations to be taken offline unless -_absolutely_ necessary - see the ["What Requires Downtime?"](what_requires_downtime.md) -page. If a migration requires downtime, this should be clearly mentioned during -the review process, as well as being documented in the monthly release post. For -more information, see the "Downtime Tagging" section below. +Migrations are **not** allowed to require GitLab installations to be taken +offline unless _absolutely necessary_. Downtime assumptions should be based on +the behaviour of a migration when performed using PostgreSQL, as various +operations in MySQL may require downtime without there being alternatives. + +When downtime is necessary the migration has to be approved by: + +1. The VP of Engineering +1. A Backend Lead +1. A Database Specialist + +An up-to-date list of people holding these titles can be found at +<https://about.gitlab.com/team/>. + +The document ["What Requires Downtime?"](what_requires_downtime.md) specifies +various database operations, whether they require downtime and how to +work around that whenever possible. When writing your migrations, also consider that databases might have stale data -or inconsistencies and guard for that. Try to make as little assumptions as possible -about the state of the database. +or inconsistencies and guard for that. Try to make as few assumptions as +possible about the state of the database. + +Please don't depend on GitLab-specific code since it can change in future +versions. If needed copy-paste GitLab code into the migration to make it forward +compatible. + +## Commit Guidelines -Please don't depend on GitLab specific code since it can change in future versions. -If needed copy-paste GitLab code into the migration to make it forward compatible. +Each migration **must** be added in its own commit with a descriptive commit +message. If a commit adds a migration it _should only_ include the migration and +any corresponding changes to `db/schema.rb`. This makes it easy to revert a +database migration without accidentally reverting other changes. ## Downtime Tagging Every migration must specify if it requires downtime or not, and if it should -require downtime it must also specify a reason for this. To do so, add the -following two constants to the migration class' body: +require downtime it must also specify a reason for this. This is required even +if 99% of the migrations won't require downtime as this makes it easier to find +the migrations that _do_ require downtime. + +To tag a migration, add the following two constants to the migration class' +body: * `DOWNTIME`: a boolean that when set to `true` indicates the migration requires downtime. @@ -50,12 +75,53 @@ from a migration class. ## Reversibility -Your migration should be reversible. This is very important, as it should +Your migration **must be** reversible. This is very important, as it should be possible to downgrade in case of a vulnerability or bugs. In your migration, add a comment describing how the reversibility of the migration was tested. +## Multi Threading + +Sometimes a migration might need to use multiple Ruby threads to speed up a +migration. For this to work your migration needs to include the module +`Gitlab::Database::MultiThreadedMigration`: + +```ruby +class MyMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + include Gitlab::Database::MultiThreadedMigration +end +``` + +You can then use the method `with_multiple_threads` to perform work in separate +threads. For example: + +```ruby +class MyMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + include Gitlab::Database::MultiThreadedMigration + + def up + with_multiple_threads(4) do + disable_statement_timeout + + # ... + end + end +end +``` + +Here the call to `disable_statement_timeout` will use the connection local to +the `with_multiple_threads` block, instead of re-using the global connection +pool. This ensures each thread has its own connection object, and won't time +out when trying to obtain one. + +**NOTE:** PostgreSQL has a maximum amount of connections that it allows. This +limit can vary from installation to installation. As a result it's recommended +you do not use more than 32 threads in a single migration. Usually 4-8 threads +should be more than enough. + ## Removing indices When removing an index make sure to use the method `remove_concurrent_index` instead @@ -78,7 +144,10 @@ end ## Adding indices -If you need to add an unique index please keep in mind there is possibility of existing duplicates. If it is possible write a separate migration for handling this situation. It can be just removing or removing with overwriting all references to these duplicates depend on situation. +If you need to add a unique index please keep in mind there is the possibility +of existing duplicates being present in the database. This means that should +always _first_ add a migration that removes any duplicates, before adding the +unique index. When adding an index make sure to use the method `add_concurrent_index` instead of the regular `add_index` method. The `add_concurrent_index` method @@ -90,17 +159,22 @@ so: ```ruby class MyMigration < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! - def change + def up + add_concurrent_index :table, :column + end + def down + remove_index :table, :column if index_exists?(:table, :column) end end ``` ## Adding Columns With Default Values -When adding columns with default values you should use the method +When adding columns with default values you must use the method `add_column_with_default`. This method ensures the table is updated without requiring downtime. This method is not reversible so you must manually define the `up` and `down` methods in your migration class. @@ -123,6 +197,9 @@ class MyMigration < ActiveRecord::Migration end ``` +Keep in mind that this operation can easily take 10-15 minutes to complete on +larger installations (e.g. GitLab.com). As a result you should only add default +values if absolutely necessary. ## Integer column type @@ -147,13 +224,15 @@ add_column(:projects, :foo, :integer, default: 10, limit: 8) ## Testing -Make sure that your migration works with MySQL and PostgreSQL with data. An empty database does not guarantee that your migration is correct. +Make sure that your migration works with MySQL and PostgreSQL with data. An +empty database does not guarantee that your migration is correct. Make sure your migration can be reversed. ## Data migration -Please prefer Arel and plain SQL over usual ActiveRecord syntax. In case of using plain SQL you need to quote all input manually with `quote_string` helper. +Please prefer Arel and plain SQL over usual ActiveRecord syntax. In case of +using plain SQL you need to quote all input manually with `quote_string` helper. Example with Arel: @@ -177,3 +256,17 @@ select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(i execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})") end ``` + +If you need more complex logic you can define and use models local to a +migration. For example: + +```ruby +class MyMigration < ActiveRecord::Migration + class Project < ActiveRecord::Base + self.table_name = 'projects' + end +end +``` + +When doing so be sure to explicitly set the model's table name so it's not +derived from the class name or namespace. diff --git a/doc/development/testing.md b/doc/development/testing.md index 5bc958f5a96..ad540ec13db 100644 --- a/doc/development/testing.md +++ b/doc/development/testing.md @@ -9,52 +9,179 @@ this guide defines a rule that contradicts the thoughtbot guide, this guide takes precedence. Some guidelines may be repeated verbatim to stress their importance. -## Factories +## Definitions + +### Unit tests + +Formal definition: https://en.wikipedia.org/wiki/Unit_testing + +These kind of tests ensure that a single unit of code (a method) works as +expected (given an input, it has a predictable output). These tests should be +isolated as much as possible. For example, model methods that don't do anything +with the database shouldn't need a DB record. Classes that don't need database +records should use stubs/doubles as much as possible. + +| Code path | Tests path | Testing engine | Notes | +| --------- | ---------- | -------------- | ----- | +| `app/finders/` | `spec/finders/` | RSpec | | +| `app/helpers/` | `spec/helpers/` | RSpec | | +| `app/db/{post_,}migrate/` | `spec/migrations/` | RSpec | | +| `app/policies/` | `spec/policies/` | RSpec | | +| `app/presenters/` | `spec/presenters/` | RSpec | | +| `app/routing/` | `spec/routing/` | RSpec | | +| `app/serializers/` | `spec/serializers/` | RSpec | | +| `app/services/` | `spec/services/` | RSpec | | +| `app/tasks/` | `spec/tasks/` | RSpec | | +| `app/uploaders/` | `spec/uploaders/` | RSpec | | +| `app/views/` | `spec/views/` | RSpec | | +| `app/workers/` | `spec/workers/` | RSpec | | +| `app/assets/javascripts/` | `spec/javascripts/` | Karma | More details in the [JavaScript](#javascript) section. | + +### Integration tests + +Formal definition: https://en.wikipedia.org/wiki/Integration_testing + +These kind of tests ensure that individual parts of the application work well together, without the overhead of the actual app environment (i.e. the browser). These tests should assert at the request/response level: status code, headers, body. They're useful to test permissions, redirections, what view is rendered etc. + +| Code path | Tests path | Testing engine | Notes | +| --------- | ---------- | -------------- | ----- | +| `app/controllers/` | `spec/controllers/` | RSpec | | +| `app/mailers/` | `spec/mailers/` | RSpec | | +| `lib/api/` | `spec/requests/api/` | RSpec | | +| `lib/ci/api/` | `spec/requests/ci/api/` | RSpec | | +| `app/assets/javascripts/` | `spec/javascripts/` | Karma | More details in the [JavaScript](#javascript) section. | + +#### About controller tests + +In an ideal world, controllers should be thin. However, when this is not the +case, it's acceptable to write a system/feature test without JavaScript instead +of a controller test. The reason is that testing a fat controller usually +involves a lot of stubbing, things like: -GitLab uses [factory_girl] as a test fixture replacement. - -- Factory definitions live in `spec/factories/`, named using the pluralization - of their corresponding model (`User` factories are defined in `users.rb`). -- There should be only one top-level factory definition per file. -- FactoryGirl methods are mixed in to all RSpec groups. This means you can (and - should) call `create(...)` instead of `FactoryGirl.create(...)`. -- Make use of [traits] to clean up definitions and usages. -- When defining a factory, don't define attributes that are not required for the - resulting record to pass validation. -- When instantiating from a factory, don't supply attributes that aren't - required by the test. -- Factories don't have to be limited to `ActiveRecord` objects. - [See example](https://gitlab.com/gitlab-org/gitlab-ce/commit/0b8cefd3b2385a21cfed779bd659978c0402766d). - -[factory_girl]: https://github.com/thoughtbot/factory_girl -[traits]: http://www.rubydoc.info/gems/factory_girl/file/GETTING_STARTED.md#Traits - -## JavaScript - -GitLab uses [Karma] to run its [Jasmine] JavaScript specs. They can be run on -the command line via `bundle exec karma`. - -- JavaScript tests live in `spec/javascripts/`, matching the folder structure - of `app/assets/javascripts/`: `app/assets/javascripts/behaviors/autosize.js` - has a corresponding `spec/javascripts/behaviors/autosize_spec.js` file. -- Haml fixtures required for JavaScript tests live in - `spec/javascripts/fixtures`. They should contain the bare minimum amount of - markup necessary for the test. - - > **Warning:** Keep in mind that a Rails view may change and - invalidate your test, but everything will still pass because your fixture - doesn't reflect the latest view. Because of this we encourage you to - generate fixtures from actual rails views whenever possible. - -- Keep in mind that in a CI environment, these tests are run in a headless - browser and you will not have access to certain APIs, such as - [`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification), - which will have to be stubbed. - -[Karma]: https://github.com/karma-runner/karma -[Jasmine]: https://github.com/jasmine/jasmine +```ruby +controller.instance_variable_set(:@user, user) +``` -For more information, see the [frontend testing guide](fe_guide/testing.md). +and use methods which are deprecated in Rails 5 ([#23768]). + +[#23768]: https://gitlab.com/gitlab-org/gitlab-ce/issues/23768 + +#### About Karma + +As you may have noticed, Karma is both in the Unit tests and the Integration +tests category. That's because Karma is a tool that provides an environment to +run JavaScript tests, so you can either run unit tests (e.g. test a single +JavaScript method), or integration tests (e.g. test a component that is composed +of multiple components). + +### System tests or Feature tests + +Formal definition: https://en.wikipedia.org/wiki/System_testing. + +These kind of tests ensure the application works as expected from a user point +of view (aka black-box testing). These tests should test a happy path for a +given page or set of pages, and a test case should be added for any regression +that couldn't have been caught at lower levels with better tests (i.e. if a +regression is found, regression tests should be added at the lowest-level +possible). + +| Tests path | Testing engine | Notes | +| ---------- | -------------- | ----- | +| `spec/features/` | [Capybara] + [RSpec] | If your spec has the `:js` metadata, the browser driver will be [Poltergeist], otherwise it's using [RackTest]. | +| `features/` | Spinach | Spinach tests are deprecated, [you shouldn't add new Spinach tests](#spinach-feature-tests). | + +[Capybara]: https://github.com/teamcapybara/capybara +[RSpec]: https://github.com/rspec/rspec-rails#feature-specs +[Poltergeist]: https://github.com/teamcapybara/capybara#poltergeist +[RackTest]: https://github.com/teamcapybara/capybara#racktest + +#### Best practices + +- Create only the necessary records in the database +- Test a happy path and a less happy path but that's it +- Every other possible path should be tested with Unit or Integration tests +- Test what's displayed on the page, not the internals of ActiveRecord models. + For instance, if you want to verify that a record was created, add + expectations that its attributes are displayed on the page, not that + `Model.count` increased by one. +- It's ok to look for DOM elements but don't abuse it since it makes the tests + more brittle + +If we're confident that the low-level components work well (and we should be if +we have enough Unit & Integration tests), we shouldn't need to duplicate their +thorough testing at the System test level. + +It's very easy to add tests, but a lot harder to remove or improve tests, so one +should take care of not introducing too many (slow and duplicated) specs. + +The reasons why we should follow these best practices are as follows: + +- System tests are slow to run since they spin up the entire application stack + in a headless browser, and even slower when they integrate a JS driver +- When system tests run with a JavaScript driver, the tests are run in a + different thread than the application. This means it does not share a + database connection and your test will have to commit the transactions in + order for the running application to see the data (and vice-versa). In that + case we need to truncate the database after each spec instead of simply + rolling back a transaction (the faster strategy that's in use for other kind + of tests). This is slower than transactions, however, so we want to use + truncation only when necessary. + +### Black-box tests or End-to-end tests + +GitLab consists of [multiple pieces] such as [GitLab Shell], [GitLab Workhorse], +[Gitaly], [GitLab Pages], [GitLab Runner], and GitLab Rails. All theses pieces +are configured and packaged by [GitLab Omnibus]. + +[GitLab QA] is a tool that allows to test that all these pieces integrate well +together by building a Docker image for a given version of GitLab Rails and +running feature tests (i.e. using Capybara) against it. + +The actual test scenarios and steps are [part of GitLab Rails] so that they're +always in-sync with the codebase. + +[multiple pieces]: ./architecture.md#components +[GitLab Shell]: https://gitlab.com/gitlab-org/gitlab-shell +[GitLab Workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse +[Gitaly]: https://gitlab.com/gitlab-org/gitaly +[GitLab Pages]: https://gitlab.com/gitlab-org/gitlab-pages +[GitLab Runner]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner +[GitLab Omnibus]: https://gitlab.com/gitlab-org/omnibus-gitlab +[GitLab QA]: https://gitlab.com/gitlab-org/gitlab-qa +[part of GitLab Rails]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/qa + +## How to test at the correct level? + +As many things in life, deciding what to test at each level of testing is a +trade-off: + +- Unit tests are usually cheap, and you should consider them like the basement + of your house: you need them to be confident that your code is behaving + correctly. However if you run only unit tests without integration / system tests, you might [miss] the [big] [picture]! +- Integration tests are a bit more expensive, but don't abuse them. A feature test + is often better than an integration test that is stubbing a lot of internals. +- System tests are expensive (compared to unit tests), even more if they require + a JavaScript driver. Make sure to follow the guidelines in the [Speed](#test-speed) + section. + +Another way to see it is to think about the "cost of tests", this is well +explained [in this article][tests-cost] and the basic idea is that the cost of a +test includes: + +- The time it takes to write the test +- The time it takes to run the test every time the suite runs +- The time it takes to understand the test +- The time it takes to fix the test if it breaks and the underlying code is OK +- Maybe, the time it takes to change the code to make the code testable. + +[miss]: https://twitter.com/ThePracticalDev/status/850748070698651649 +[big]: https://twitter.com/timbray/status/822470746773409794 +[picture]: https://twitter.com/withzombies/status/829716565834752000 +[tests-cost]: https://medium.com/table-xi/high-cost-tests-and-high-value-tests-a86e27a54df#.2ulyh3a4e + +## Frontend testing + +Please consult the [dedicated "Frontend testing" guide](./fe_guide/testing.md). ## RSpec @@ -117,53 +244,124 @@ it 'is overdue' do end ``` -### Test speed +### System / Feature tests -GitLab has a massive test suite that, without parallelization, can take more -than an hour to run. It's important that we make an effort to write tests that -are accurate and effective _as well as_ fast. +- Feature specs should be named `ROLE_ACTION_spec.rb`, such as + `user_changes_password_spec.rb`. +- Use only one `feature` block per feature spec file. +- Use scenario titles that describe the success and failure paths. +- Avoid scenario titles that add no information, such as "successfully". +- Avoid scenario titles that repeat the feature title. -Here are some things to keep in mind regarding test performance: +### Matchers -- `double` and `spy` are faster than `FactoryGirl.build(...)` -- `FactoryGirl.build(...)` and `.build_stubbed` are faster than `.create`. -- Don't `create` an object when `build`, `build_stubbed`, `attributes_for`, - `spy`, or `double` will do. Database persistence is slow! -- Use `create(:empty_project)` instead of `create(:project)` when you don't need - the underlying Git repository. Filesystem operations are slow! -- Don't mark a feature as requiring JavaScript (through `@javascript` in - Spinach or `js: true` in RSpec) unless it's _actually_ required for the test - to be valid. Headless browser testing is slow! +Custom matchers should be created to clarify the intent and/or hide the +complexity of RSpec expectations.They should be placed under +`spec/support/matchers/`. Matchers can be placed in subfolder if they apply to +a certain type of specs only (e.g. features, requests etc.) but shouldn't be if +they apply to multiple type of specs. -### Features / Integration +### Shared contexts -GitLab uses [rspec-rails feature specs] to test features in a browser -environment. These are [capybara] specs running on the headless [poltergeist] -driver. +All shared contexts should be be placed under `spec/support/shared_contexts/`. +Shared contexts can be placed in subfolder if they apply to a certain type of +specs only (e.g. features, requests etc.) but shouldn't be if they apply to +multiple type of specs. -- Feature specs live in `spec/features/` and should be named - `ROLE_ACTION_spec.rb`, such as `user_changes_password_spec.rb`. -- Use only one `feature` block per feature spec file. -- Use scenario titles that describe the success and failure paths. -- Avoid scenario titles that add no information, such as "successfully." -- Avoid scenario titles that repeat the feature title. +Each file should include only one context and have a descriptive name, e.g. +`spec/support/shared_contexts/controllers/githubish_import_controller_shared_context.rb`. -[rspec-rails feature specs]: https://github.com/rspec/rspec-rails#feature-specs -[capybara]: https://github.com/teamcapybara/capybara -[poltergeist]: https://github.com/teampoltergeist/poltergeist +### Shared examples -## Spinach (feature) tests +All shared examples should be be placed under `spec/support/shared_examples/`. +Shared examples can be placed in subfolder if they apply to a certain type of +specs only (e.g. features, requests etc.) but shouldn't be if they apply to +multiple type of specs. -GitLab [moved from Cucumber to Spinach](https://github.com/gitlabhq/gitlabhq/pull/1426) -for its feature/integration tests in September 2012. +Each file should include only one context and have a descriptive name, e.g. +`spec/support/shared_examples/controllers/githubish_import_controller_shared_example.rb`. -As of March 2016, we are [trying to avoid adding new Spinach -tests](https://gitlab.com/gitlab-org/gitlab-ce/issues/14121) going forward, -opting for [RSpec feature](#features-integration) specs. +### Helpers -Adding new Spinach scenarios is acceptable _only if_ the new scenario requires -no more than one new `step` definition. If more than that is required, the -test should be re-implemented using RSpec instead. +Helpers are usually modules that provide some methods to hide the complexity of +specific RSpec examples. You can define helpers in RSpec files if they're not +intended to be shared with other specs. Otherwise, they should be be placed +under `spec/support/helpers/`. Helpers can be placed in subfolder if they apply +to a certain type of specs only (e.g. features, requests etc.) but shouldn't be +if they apply to multiple type of specs. + +Helpers should follow the Rails naming / namespacing convention. For instance +`spec/support/helpers/cycle_analytics_helpers.rb` should define: + +```ruby +module Spec + module Support + module Helpers + module CycleAnalyticsHelpers + def create_commit_referencing_issue(issue, branch_name: random_git_name) + project.repository.add_branch(user, branch_name, 'master') + create_commit("Commit for ##{issue.iid}", issue.project, user, branch_name) + end + end + end + end +end +``` + +Helpers should not change the RSpec config. For instance, the helpers module +described above should not include: + +```ruby +RSpec.configure do |config| + config.include Spec::Support::Helpers::CycleAnalyticsHelpers +end +``` + +### Factories + +GitLab uses [factory_girl] as a test fixture replacement. + +- Factory definitions live in `spec/factories/`, named using the pluralization + of their corresponding model (`User` factories are defined in `users.rb`). +- There should be only one top-level factory definition per file. +- FactoryGirl methods are mixed in to all RSpec groups. This means you can (and + should) call `create(...)` instead of `FactoryGirl.create(...)`. +- Make use of [traits] to clean up definitions and usages. +- When defining a factory, don't define attributes that are not required for the + resulting record to pass validation. +- When instantiating from a factory, don't supply attributes that aren't + required by the test. +- Factories don't have to be limited to `ActiveRecord` objects. + [See example](https://gitlab.com/gitlab-org/gitlab-ce/commit/0b8cefd3b2385a21cfed779bd659978c0402766d). + +[factory_girl]: https://github.com/thoughtbot/factory_girl +[traits]: http://www.rubydoc.info/gems/factory_girl/file/GETTING_STARTED.md#Traits + +### Fixtures + +All fixtures should be be placed under `spec/fixtures/`. + +### Config + +RSpec config files are files that change the RSpec config (i.e. +`RSpec.configure do |config|` blocks). They should be placed under +`spec/support/config/`. + +Each file should be related to a specific domain, e.g. +`spec/support/config/capybara.rb`, `spec/support/config/carrierwave.rb`, etc. + +Helpers can be included in the `spec/support/config/rspec.rb` file. If a +helpers module applies only to a certain kind of specs, it should add modifiers +to the `config.include` call. For instance if +`spec/support/helpers/cycle_analytics_helpers.rb` applies to `:lib` and +`type: :model` specs only, you would write the following: + +```ruby +RSpec.configure do |config| + config.include Spec::Support::Helpers::CycleAnalyticsHelpers, :lib + config.include Spec::Support::Helpers::CycleAnalyticsHelpers, type: :model +end +``` ## Testing Rake Tasks @@ -201,6 +399,77 @@ describe 'gitlab:shell rake tasks' do end ``` +## Test speed + +GitLab has a massive test suite that, without [parallelization], can take hours +to run. It's important that we make an effort to write tests that are accurate +and effective _as well as_ fast. + +Here are some things to keep in mind regarding test performance: + +- `double` and `spy` are faster than `FactoryGirl.build(...)` +- `FactoryGirl.build(...)` and `.build_stubbed` are faster than `.create`. +- Don't `create` an object when `build`, `build_stubbed`, `attributes_for`, + `spy`, or `double` will do. Database persistence is slow! +- Use `create(:empty_project)` instead of `create(:project)` when you don't need + the underlying Git repository. Filesystem operations are slow! +- Don't mark a feature as requiring JavaScript (through `@javascript` in + Spinach or `:js` in RSpec) unless it's _actually_ required for the test + to be valid. Headless browser testing is slow! + +[parallelization]: #test-suite-parallelization-on-the-ci + +### Test suite parallelization on the CI + +Our current CI parallelization setup is as follows: + +1. The `knapsack` job in the prepare stage that is supposed to ensure we have a + `knapsack/${CI_PROJECT_NAME}/rspec_report-master.json` file: + - The `knapsack/${CI_PROJECT_NAME}/rspec_report-master.json` file is fetched + from S3, if it's not here we initialize the file with `{}`. +1. Each `rspec x y` job are run with `knapsack rspec` and should have an evenly + distributed share of tests: + - It works because the jobs have access to the + `knapsack/${CI_PROJECT_NAME}/rspec_report-master.json` since the "artifacts + from all previous stages are passed by default". [^1] + - the jobs set their own report path to + `KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json`. + - if knapsack is doing its job, test files that are run should be listed under + `Report specs`, not under `Leftover specs`. +1. The `update-knapsack` job takes all the + `knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json` + files from the `rspec x y` jobs and merge them all together into a single + `knapsack/${CI_PROJECT_NAME}/rspec_report-master.json` file that is then + uploaded to S3. + +After that, the next pipeline will use the up-to-date +`knapsack/${CI_PROJECT_NAME}/rspec_report-master.json` file. The same strategy +is used for Spinach tests as well. + +### Monitoring + +The GitLab test suite is [monitored] and a [public dashboard] is available for +everyone to see. Feel free to look at the slowest test files and try to improve +them. + +[monitored]: ./performance.md#rspec-profiling +[public dashboard]: https://redash.gitlab.com/public/dashboards/l1WhHXaxrCWM5Ai9D7YDqHKehq6OU3bx5gssaiWe?org_slug=default + +## Spinach (feature) tests + +GitLab [moved from Cucumber to Spinach](https://github.com/gitlabhq/gitlabhq/pull/1426) +for its feature/integration tests in September 2012. + +As of March 2016, we are [trying to avoid adding new Spinach +tests](https://gitlab.com/gitlab-org/gitlab-ce/issues/14121) going forward, +opting for [RSpec feature](#features-integration) specs. + +Adding new Spinach scenarios is acceptable _only if_ the new scenario requires +no more than one new `step` definition. If more than that is required, the +test should be re-implemented using RSpec instead. + --- [Return to Development documentation](README.md) + +[^1]: /ci/yaml/README.html#dependencies diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md index bbcd26477f3..8da6ad684f5 100644 --- a/doc/development/what_requires_downtime.md +++ b/doc/development/what_requires_downtime.md @@ -2,7 +2,8 @@ When working with a database certain operations can be performed without taking GitLab offline, others do require a downtime period. This guide describes -various operations and their impact. +various operations, their impact, and how to perform them without requiring +downtime. ## Adding Columns @@ -41,50 +42,156 @@ information on how to use this method. ## Dropping Columns -On PostgreSQL you can safely remove an existing column without the need for -downtime. When you drop a column in PostgreSQL it's not immediately removed, -instead it is simply disabled. The data is removed on the next vacuum run. +Removing columns is tricky because running GitLab processes may still be using +the columns. To work around this you will need two separate merge requests and +releases: one to ignore and then remove the column, and one to remove the ignore +rule. -On MySQL this operation requires downtime. +### Step 1: Ignoring The Column -While database wise dropping a column may be fine on PostgreSQL this operation -still requires downtime because the application code may still be using the -column that was removed. For example, consider the following migration: +The first step is to ignore the column in the application code. This is +necessary because Rails caches the columns and re-uses this cache in various +places. This can be done by including the `IgnorableColumn` module into the +model, followed by defining the columns to ignore. For example, to ignore +`updated_at` in the User model you'd use the following: ```ruby -class MyMigration < ActiveRecord::Migration - def change - remove_column :projects, :dummy - end +class User < ActiveRecord::Base + include IgnorableColumn + + ignore_column :updated_at end ``` -Now imagine that the GitLab instance is running and actively uses the `dummy` -column. If we were to run the migration this would result in the GitLab instance -producing errors whenever it tries to use the `dummy` column. +Once added you should create a _post-deployment_ migration that removes the +column. Both these changes should be submitted in the same merge request. -As a result of the above downtime _is_ required when removing a column, even -when using PostgreSQL. +### Step 2: Removing The Ignore Rule + +Once the changes from step 1 have been released & deployed you can set up a +separate merge request that removes the ignore rule. This merge request can +simply remove the `ignore_column` line, and the `include IgnorableColumn` line +if no other `ignore_column` calls remain. ## Renaming Columns -Renaming columns requires downtime as running GitLab instances will continue -using the old column name until a new version is deployed. This can result -in the instance producing errors, which in turn can impact the user experience. +Renaming columns the normal way requires downtime as an application may continue +using the old column name during/after a database migration. To rename a column +without requiring downtime we need two migrations: a regular migration, and a +post-deployment migration. Both these migration can go in the same release. -## Changing Column Constraints +### Step 1: Add The Regular Migration + +First we need to create the regular migration. This migration should use +`Gitlab::Database::MigrationHelpers#rename_column_concurrently` to perform the +renaming. For example + +```ruby +# A regular migration in db/migrate +class RenameUsersUpdatedAtToUpdatedAtTimestamp < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def up + rename_column_concurrently :users, :updated_at, :updated_at_timestamp + end + + def down + cleanup_concurrent_column_rename :users, :updated_at_timestamp, :updated_at + end +end +``` + +This will take care of renaming the column, ensuring data stays in sync, copying +over indexes and foreign keys, etc. + +**NOTE:** if a column contains 1 or more indexes that do not contain the name of +the original column, the above procedure will fail. In this case you will first +need to rename these indexes. -Generally changing column constraints requires checking all rows in the table to -see if they meet the new constraint, unless a constraint is _removed_. For -example, changing a column that previously allowed NULL values to not allow NULL -values requires the database to verify all existing rows. +### Step 2: Add A Post-Deployment Migration -The specific behaviour varies a bit between databases but in general the safest -approach is to assume changing constraints requires downtime. +The renaming procedure requires some cleaning up in a post-deployment migration. +We can perform this cleanup using +`Gitlab::Database::MigrationHelpers#cleanup_concurrent_column_rename`: + +```ruby +# A post-deployment migration in db/post_migrate +class CleanupUsersUpdatedAtRename < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :users, :updated_at, :updated_at_timestamp + end + + def down + rename_column_concurrently :users, :updated_at_timestamp, :updated_at + end +end +``` + +## Changing Column Constraints + +Adding or removing a NOT NULL clause (or another constraint) can typically be +done without requiring downtime. However, this does require that any application +changes are deployed _first_. Thus, changing the constraints of a column should +happen in a post-deployment migration. ## Changing Column Types -This operation requires downtime. +Changing the type of a column can be done using +`Gitlab::Database::MigrationHelpers#change_column_type_concurrently`. This +method works similarly to `rename_column_concurrently`. For example, let's say +we want to change the type of `users.username` from `string` to `text`. + +### Step 1: Create A Regular Migration + +A regular migration is used to create a new column with a temporary name along +with setting up some triggers to keep data in sync. Such a migration would look +as follows: + +```ruby +# A regular migration in db/migrate +class ChangeUsersUsernameStringToText < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def up + change_column_type_concurrently :users, :username, :text + end + + def down + cleanup_concurrent_column_type_change :users, :username + end +end +``` + +### Step 2: Create A Post Deployment Migration + +Next we need to clean up our changes using a post-deployment migration: + +```ruby +# A post-deployment migration in db/post_migrate +class ChangeUsersUsernameStringToTextCleanup < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_type_change :users + end + + def down + change_column_type_concurrently :users, :username, :string + end +end +``` + +And that's it, we're done! ## Adding Indexes @@ -101,12 +208,19 @@ Migrations can take advantage of this by using the method ```ruby class MyMigration < ActiveRecord::Migration - def change + def up add_concurrent_index :projects, :column_name end + + def down + remove_index(:projects, :column_name) if index_exists?(:projects, :column_name) + end end ``` +Note that `add_concurrent_index` can not be reversed automatically, thus you +need to manually define `up` and `down`. + When running this on PostgreSQL the `CONCURRENTLY` option mentioned above is used. On MySQL this method produces a regular `CREATE INDEX` query. @@ -125,43 +239,54 @@ This operation is safe as there's no code using the table just yet. ## Dropping Tables -This operation requires downtime as application code may still be using the -table. +Dropping tables can be done safely using a post-deployment migration, but only +if the application no longer uses the table. ## Adding Foreign Keys -Adding foreign keys acquires an exclusive lock on both the source and target -tables in PostgreSQL. This requires downtime as otherwise the entire application -grinds to a halt for the duration of the operation. +Adding foreign keys usually works in 3 steps: + +1. Start a transaction +1. Run `ALTER TABLE` to add the constraint(s) +1. Check all existing data -On MySQL this operation also requires downtime _unless_ foreign key checks are -disabled. Because this means checks aren't enforced this is not ideal, as such -one should assume MySQL also requires downtime. +Because `ALTER TABLE` typically acquires an exclusive lock until the end of a +transaction this means this approach would require downtime. + +GitLab allows you to work around this by using +`Gitlab::Database::MigrationHelpers#add_concurrent_foreign_key`. This method +ensures that when PostgreSQL is used no downtime is needed. ## Removing Foreign Keys -This operation should not require downtime on both PostgreSQL and MySQL. +This operation does not require downtime. -## Updating Data +## Data Migrations -Updating data should generally be safe. The exception to this is data that's -being migrated from one version to another while the application still produces -data in the old version. +Data migrations can be tricky. The usual approach to migrate data is to take a 3 +step approach: -For example, imagine the application writes the string `'dog'` to a column but -it really is meant to write `'cat'` instead. One might think that the following -migration is all that is needed to solve this problem: +1. Migrate the initial batch of data +1. Deploy the application code +1. Migrate any remaining data -```ruby -class MyMigration < ActiveRecord::Migration - def up - execute("UPDATE some_table SET column = 'cat' WHERE column = 'dog';") - end -end -``` +Usually this works, but not always. For example, if a field's format is to be +changed from JSON to something else we have a bit of a problem. If we were to +change existing data before deploying application code we'll most likely run +into errors. On the other hand, if we were to migrate after deploying the +application code we could run into the same problems. + +If you merely need to correct some invalid data, then a post-deployment +migration is usually enough. If you need to change the format of data (e.g. from +JSON to something else) it's typically best to add a new column for the new data +format, and have the application use that. In such a case the procedure would +be: -Unfortunately this is not enough. Because the application is still running and -using the old value this may result in the table still containing rows where -`column` is set to `dog`, even after the migration finished. +1. Add a new column in the new format +1. Copy over existing data to this new column +1. Deploy the application code +1. In a post-deployment migration, copy over any remaining data -In these cases downtime _is_ required, even for rarely updated tables. +In general there is no one-size-fits-all solution, therefore it's best to +discuss these kind of migrations in a merge request to make sure they are +implemented in the best way possible. diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md new file mode 100644 index 00000000000..eafd2fd9d04 --- /dev/null +++ b/doc/topics/authentication/index.md @@ -0,0 +1,46 @@ +# Authentication + +This page gathers all the resources for the topic **Authentication** within GitLab. + +## GitLab users + +- [SSH](../../ssh/README.md) +- [Two-Factor Authentication (2FA)](../../user/profile/account/two_factor_authentication.md#two-factor-authentication) +- **Articles:** + - [Support for Universal 2nd Factor Authentication - YubiKeys](https://about.gitlab.com/2016/06/22/gitlab-adds-support-for-u2f/) + - [Security Webcast with Yubico](https://about.gitlab.com/2016/08/31/gitlab-and-yubico-security-webcast/) +- **Integrations:** + - [GitLab as OAuth2 authentication service provider](../../integration/oauth_provider.md#introduction-to-oauth) + +## GitLab administrators + +- [LDAP (Community Edition)](../../administration/auth/ldap.md) +- [LDAP (Enterprise Edition)](https://docs.gitlab.com/ee/administration/auth/ldap-ee.html) +- [Enforce Two-factor Authentication (2FA)](../../security/two_factor_authentication.md#enforce-two-factor-authentication-2fa) +- **Articles:** + - [Feature Highlight: LDAP Integration](https://about.gitlab.com/2014/07/10/feature-highlight-ldap-sync/) + - [Debugging LDAP](https://about.gitlab.com/handbook/support/workflows/ldap/debugging_ldap.html) +- **Integrations:** + - [OmniAuth](../../integration/omniauth.md) + - [Authentiq OmniAuth Provider](../../administration/auth/authentiq.md#authentiq-omniauth-provider) + - [Atlassian Crowd OmniAuth Provider](../../administration/auth/crowd.md) + - [CAS OmniAuth Provider](../../integration/cas.md) + - [SAML OmniAuth Provider](../../integration/saml.md) + - [Okta SSO provider](../../administration/auth/okta.md) + - [Kerberos integration (GitLab EE)](https://docs.gitlab.com/ee/integration/kerberos.html) + +## API + +- [OAuth 2 Tokens](../../api/README.md#oauth-2-tokens) +- [Private Tokens](../../api/README.md#private-tokens) +- [Impersonation tokens](../../api/README.md#impersonation-tokens) +- [GitLab as an OAuth2 provider](../../api/oauth2.md#gitlab-as-an-oauth2-provider) +- [GitLab Runner API - Authentication](../../api/ci/runners.md#authentication) + +## Third-party resources + +- [Kanboard Plugin GitLab Authentication](https://kanboard.net/plugin/gitlab-auth) +- [Jenkins GitLab OAuth Plugin](https://wiki.jenkins-ci.org/display/JENKINS/GitLab+OAuth+Plugin) +- [Setup Gitlab CE with Active Directory authentication](https://www.caseylabs.com/setup-gitlab-ce-with-active-directory-authentication/) +- [How to customize GitLab to support OpenID authentication](http://eric.van-der-vlist.com/blog/2013/11/23/how-to-customize-gitlab-to-support-openid-authentication/) +- [Openshift - Configuring Authentication and User Agent](https://docs.openshift.org/latest/install_config/configuring_authentication.html#GitLab) diff --git a/doc/topics/index.md b/doc/topics/index.md index 0d367bae204..ad388dff822 100644 --- a/doc/topics/index.md +++ b/doc/topics/index.md @@ -7,6 +7,7 @@ you through better understanding GitLab's concepts through our regular docs, and, when available, through articles (guides, tutorials, technical overviews, blog posts) and videos. +- [Authentication](authentication/index.md) - [Continuous Integration (GitLab CI)](../ci/README.md) - [Git](git/index.md) - [GitLab Installation](../install/README.md) diff --git a/doc/update/README.md b/doc/update/README.md index 837b31abb97..d024a809f24 100644 --- a/doc/update/README.md +++ b/doc/update/README.md @@ -48,6 +48,20 @@ GitLab provides official Docker images for both Community and Enterprise editions. They are based on the Omnibus package and instructions on how to update them are in [a separate document][omnidocker]. +## Upgrading without downtime + +Starting with GitLab 9.1.0 it's possible to upgrade to a newer major, minor, or patch version of GitLab +without having to take your GitLab instance offline. However, for this to work +there are the following requirements: + +1. You can only upgrade 1 minor release at a time. So from 9.1 to 9.2, not to 9.3. +2. You have to be on the most recent patch release. For example, if 9.1.15 is the last + release of 9.1 then you can safely upgrade from that version to any 9.2.x version. + However, if you are running 9.1.14 you first need to upgrade to 9.1.15. +2. You have to use [post-deployment + migrations](../development/post_deployment_migrations.md). +3. You are using PostgreSQL. If you are using MySQL please look at the release post to see if downtime is required. + ## Upgrading between editions GitLab comes in two flavors: [Community Edition][ce] which is MIT licensed, diff --git a/doc/user/project/cycle_analytics.md b/doc/user/project/cycle_analytics.md index 62afd8cf247..8f6b530c033 100644 --- a/doc/user/project/cycle_analytics.md +++ b/doc/user/project/cycle_analytics.md @@ -5,10 +5,10 @@ Cycle Analytics measures the time it takes to go from an [idea to production] for each project you have. This is achieved by not only indicating the total time it -takes to reach at that point, but the total time is broken down into the +takes to reach that point, but the total time is broken down into the multiple stages an idea has to pass through to be shipped. -Cycle Analytics is that it is tightly coupled with the [GitLab flow] and +Cycle Analytics is tightly coupled with the [GitLab flow] and calculates a separate median for each stage. ## Overview diff --git a/doc/user/project/integrations/kubernetes.md b/doc/user/project/integrations/kubernetes.md index 2a890acde4d..f7d5e3a8ab2 100644 --- a/doc/user/project/integrations/kubernetes.md +++ b/doc/user/project/integrations/kubernetes.md @@ -60,7 +60,7 @@ to use terminals. Support is currently limited to the first container in the first pod of your environment. When enabled, the Kubernetes service adds [web terminal](../../../ci/environments.md#web-terminals) -support to your environments. This is based on the `exec` functionality found in +support to your [environments](../../../ci/environments.md). This is based on the `exec` functionality found in Docker and Kubernetes, so you get a new shell session within your existing containers. To use this integration, you should deploy to Kubernetes using the deployment variables above, ensuring any pods you create are labelled with diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 53791166c33..87dfd1573a4 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -13,7 +13,7 @@ module API optional :merge_requests_events, type: Boolean, desc: "Trigger hook on merge request events" optional :tag_push_events, type: Boolean, desc: "Trigger hook on tag push events" optional :note_events, type: Boolean, desc: "Trigger hook on note(comment) events" - optional :build_events, type: Boolean, desc: "Trigger hook on build events" + optional :job_events, type: Boolean, desc: "Trigger hook on job events" optional :pipeline_events, type: Boolean, desc: "Trigger hook on pipeline events" optional :wiki_page_events, type: Boolean, desc: "Trigger hook on wiki events" optional :enable_ssl_verification, type: Boolean, desc: "Do SSL verification when triggering the hook" @@ -53,7 +53,10 @@ module API use :project_hook_properties end post ":id/hooks" do - hook = user_project.hooks.new(declared_params(include_missing: false)) + hook_params = declared_params(include_missing: false) + hook_params[:build_events] = hook_params.delete(:job_events) { false } + + hook = user_project.hooks.new(hook_params) if hook.save present hook, with: Entities::ProjectHook @@ -74,7 +77,10 @@ module API put ":id/hooks/:hook_id" do hook = user_project.hooks.find(params.delete(:hook_id)) - if hook.update_attributes(declared_params(include_missing: false)) + update_params = declared_params(include_missing: false) + update_params[:build_events] = update_params.delete(:job_events) if update_params[:job_events] + + if hook.update_attributes(update_params) present hook, with: Entities::ProjectHook else error!("Invalid url given", 422) if hook.errors[:url].present? diff --git a/lib/banzai/filter/issuable_state_filter.rb b/lib/banzai/filter/issuable_state_filter.rb index 6b78aa795b4..0b2b8bd7f4d 100644 --- a/lib/banzai/filter/issuable_state_filter.rb +++ b/lib/banzai/filter/issuable_state_filter.rb @@ -13,8 +13,8 @@ module Banzai issuables = extractor.extract([doc]) issuables.each do |node, issuable| - if VISIBLE_STATES.include?(issuable.state) - node.children.last.content += " [#{issuable.state}]" + if VISIBLE_STATES.include?(issuable.state) && node.children.present? + node.add_child(Nokogiri::XML::Text.new(" [#{issuable.state}]", doc)) end end diff --git a/lib/bitbucket/representation/base.rb b/lib/bitbucket/representation/base.rb index 94adaacc9b5..800d5a075c6 100644 --- a/lib/bitbucket/representation/base.rb +++ b/lib/bitbucket/representation/base.rb @@ -1,6 +1,8 @@ module Bitbucket module Representation class Base + attr_reader :raw + def initialize(raw) @raw = raw end @@ -8,10 +10,6 @@ module Bitbucket def self.decorate(entries) entries.map { |entry| new(entry)} end - - private - - attr_reader :raw end end end diff --git a/lib/container_registry/path.rb b/lib/container_registry/path.rb index a4b5f2aba6c..4a585996aa5 100644 --- a/lib/container_registry/path.rb +++ b/lib/container_registry/path.rb @@ -15,7 +15,7 @@ module ContainerRegistry LEVELS_SUPPORTED = 3 def initialize(path) - @path = path + @path = path.to_s.downcase end def valid? @@ -25,7 +25,7 @@ module ContainerRegistry end def components - @components ||= @path.to_s.split('/') + @components ||= @path.split('/') end def nodes diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index f4efa20374a..5a6d9ae99a0 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -149,7 +149,7 @@ module Gitlab description += @formatter.author_line(pull_request.author) unless find_user_id(pull_request.author) description += pull_request.description - merge_request = project.merge_requests.create( + merge_request = project.merge_requests.create!( iid: pull_request.iid, title: pull_request.title, description: description, @@ -168,7 +168,7 @@ module Gitlab import_pull_request_comments(pull_request, merge_request) if merge_request.persisted? rescue StandardError => e - errors << { type: :pull_request, iid: pull_request.iid, errors: e.message } + errors << { type: :pull_request, iid: pull_request.iid, errors: e.message, trace: e.backtrace.join("\n"), raw_response: pull_request.raw } end end end diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb index 2af94e2c60e..3b335cdfd01 100644 --- a/lib/gitlab/ci/trace/stream.rb +++ b/lib/gitlab/ci/trace/stream.rb @@ -25,11 +25,10 @@ module Gitlab end def limit(last_bytes = LIMIT_SIZE) - stream_size = size - if stream_size < last_bytes - last_bytes = stream_size + if last_bytes < size + stream.seek(-last_bytes, IO::SEEK_END) + stream.readline end - stream.seek(-last_bytes, IO::SEEK_END) end def append(data, offset) @@ -76,11 +75,14 @@ module Gitlab stream.each_line do |line| matches = line.scan(regex) next unless matches.is_a?(Array) + next if matches.empty? match = matches.flatten.last coverage = match.gsub(/\d+(\.\d+)?/).first - return coverage.to_f if coverage.present? + return coverage if coverage.present? end + + nil rescue # if bad regex or something goes wrong we dont want to interrupt transition # so we just silentrly ignore error for now diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 63b8d0d3b9d..d0bd1299671 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -57,16 +57,16 @@ module Gitlab postgresql? ? "RANDOM()" : "RAND()" end - def true_value - if Gitlab::Database.postgresql? + def self.true_value + if postgresql? "'t'" else 1 end end - def false_value - if Gitlab::Database.postgresql? + def self.false_value + if postgresql? "'f'" else 0 diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 525aa920328..6dabbe0264c 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -89,7 +89,8 @@ module Gitlab ADD CONSTRAINT #{key_name} FOREIGN KEY (#{column}) REFERENCES #{target} (id) - ON DELETE #{on_delete} NOT VALID; + #{on_delete ? "ON DELETE #{on_delete}" : ''} + NOT VALID; EOF # Validate the existing constraint. This can potentially take a very @@ -114,6 +115,14 @@ module Gitlab execute('SET statement_timeout TO 0') if Database.postgresql? end + def true_value + Database.true_value + end + + def false_value + Database.false_value + end + # Updates the value of a column in batches. # # This method updates the table in batches of 5% of the total row count. @@ -250,6 +259,245 @@ module Gitlab raise error end end + + # Renames a column without requiring downtime. + # + # Concurrent renames work by using database triggers to ensure both the + # old and new column are in sync. However, this method will _not_ remove + # the triggers or the old column automatically; this needs to be done + # manually in a post-deployment migration. This can be done using the + # method `cleanup_concurrent_column_rename`. + # + # table - The name of the database table containing the column. + # old - The old column name. + # new - The new column name. + # type - The type of the new column. If no type is given the old column's + # type is used. + def rename_column_concurrently(table, old, new, type: nil) + if transaction_open? + raise 'rename_column_concurrently can not be run inside a transaction' + end + + trigger_name = rename_trigger_name(table, old, new) + quoted_table = quote_table_name(table) + quoted_old = quote_column_name(old) + quoted_new = quote_column_name(new) + + if Database.postgresql? + install_rename_triggers_for_postgresql(trigger_name, quoted_table, + quoted_old, quoted_new) + else + install_rename_triggers_for_mysql(trigger_name, quoted_table, + quoted_old, quoted_new) + end + + old_col = column_for(table, old) + new_type = type || old_col.type + + add_column(table, new, new_type, + limit: old_col.limit, + default: old_col.default, + null: old_col.null, + precision: old_col.precision, + scale: old_col.scale) + + update_column_in_batches(table, new, Arel::Table.new(table)[old]) + + copy_indexes(table, old, new) + copy_foreign_keys(table, old, new) + end + + # Changes the type of a column concurrently. + # + # table - The table containing the column. + # column - The name of the column to change. + # new_type - The new column type. + def change_column_type_concurrently(table, column, new_type) + temp_column = "#{column}_for_type_change" + + rename_column_concurrently(table, column, temp_column, type: new_type) + end + + # Performs cleanup of a concurrent type change. + # + # table - The table containing the column. + # column - The name of the column to change. + # new_type - The new column type. + def cleanup_concurrent_column_type_change(table, column) + temp_column = "#{column}_for_type_change" + + transaction do + # This has to be performed in a transaction as otherwise we might have + # inconsistent data. + cleanup_concurrent_column_rename(table, column, temp_column) + rename_column(table, temp_column, column) + end + end + + # Cleans up a concurrent column name. + # + # This method takes care of removing previously installed triggers as well + # as removing the old column. + # + # table - The name of the database table. + # old - The name of the old column. + # new - The name of the new column. + def cleanup_concurrent_column_rename(table, old, new) + trigger_name = rename_trigger_name(table, old, new) + + if Database.postgresql? + remove_rename_triggers_for_postgresql(table, trigger_name) + else + remove_rename_triggers_for_mysql(trigger_name) + end + + remove_column(table, old) + end + + # Performs a concurrent column rename when using PostgreSQL. + def install_rename_triggers_for_postgresql(trigger, table, old, new) + execute <<-EOF.strip_heredoc + CREATE OR REPLACE FUNCTION #{trigger}() + RETURNS trigger AS + $BODY$ + BEGIN + NEW.#{new} := NEW.#{old}; + RETURN NEW; + END; + $BODY$ + LANGUAGE 'plpgsql' + VOLATILE + EOF + + execute <<-EOF.strip_heredoc + CREATE TRIGGER #{trigger} + BEFORE INSERT OR UPDATE + ON #{table} + FOR EACH ROW + EXECUTE PROCEDURE #{trigger}() + EOF + end + + # Installs the triggers necessary to perform a concurrent column rename on + # MySQL. + def install_rename_triggers_for_mysql(trigger, table, old, new) + execute <<-EOF.strip_heredoc + CREATE TRIGGER #{trigger}_insert + BEFORE INSERT + ON #{table} + FOR EACH ROW + SET NEW.#{new} = NEW.#{old} + EOF + + execute <<-EOF.strip_heredoc + CREATE TRIGGER #{trigger}_update + BEFORE UPDATE + ON #{table} + FOR EACH ROW + SET NEW.#{new} = NEW.#{old} + EOF + end + + # Removes the triggers used for renaming a PostgreSQL column concurrently. + def remove_rename_triggers_for_postgresql(table, trigger) + execute("DROP TRIGGER #{trigger} ON #{table}") + execute("DROP FUNCTION #{trigger}()") + end + + # Removes the triggers used for renaming a MySQL column concurrently. + def remove_rename_triggers_for_mysql(trigger) + execute("DROP TRIGGER #{trigger}_insert") + execute("DROP TRIGGER #{trigger}_update") + end + + # Returns the (base) name to use for triggers when renaming columns. + def rename_trigger_name(table, old, new) + 'trigger_' + Digest::SHA256.hexdigest("#{table}_#{old}_#{new}").first(12) + end + + # Returns an Array containing the indexes for the given column + def indexes_for(table, column) + column = column.to_s + + indexes(table).select { |index| index.columns.include?(column) } + end + + # Returns an Array containing the foreign keys for the given column. + def foreign_keys_for(table, column) + column = column.to_s + + foreign_keys(table).select { |fk| fk.column == column } + end + + # Copies all indexes for the old column to a new column. + # + # table - The table containing the columns and indexes. + # old - The old column. + # new - The new column. + def copy_indexes(table, old, new) + old = old.to_s + new = new.to_s + + indexes_for(table, old).each do |index| + new_columns = index.columns.map do |column| + column == old ? new : column + end + + # This is necessary as we can't properly rename indexes such as + # "ci_taggings_idx". + unless index.name.include?(old) + raise "The index #{index.name} can not be copied as it does not "\ + "mention the old column. You have to rename this index manually first." + end + + name = index.name.gsub(old, new) + + options = { + unique: index.unique, + name: name, + length: index.lengths, + order: index.orders + } + + # These options are not supported by MySQL, so we only add them if + # they were previously set. + options[:using] = index.using if index.using + options[:where] = index.where if index.where + + unless index.opclasses.blank? + opclasses = index.opclasses.dup + + # Copy the operator classes for the old column (if any) to the new + # column. + opclasses[new] = opclasses.delete(old) if opclasses[old] + + options[:opclasses] = opclasses + end + + add_concurrent_index(table, new_columns, options) + end + end + + # Copies all foreign keys for the old column to the new column. + # + # table - The table containing the columns and indexes. + # old - The old column. + # new - The new column. + def copy_foreign_keys(table, old, new) + foreign_keys_for(table, old).each do |fk| + add_concurrent_foreign_key(fk.from_table, + fk.to_table, + column: new, + on_delete: fk.on_delete) + end + end + + # Returns the column for the given table and column name. + def column_for(table, name) + name = name.to_s + + columns(table).find { |column| column.name == name } + end end end end diff --git a/lib/gitlab/database/multi_threaded_migration.rb b/lib/gitlab/database/multi_threaded_migration.rb new file mode 100644 index 00000000000..7ae5a4c17c8 --- /dev/null +++ b/lib/gitlab/database/multi_threaded_migration.rb @@ -0,0 +1,52 @@ +module Gitlab + module Database + module MultiThreadedMigration + MULTI_THREAD_AR_CONNECTION = :thread_local_ar_connection + + # This overwrites the default connection method so that every thread can + # use a thread-local connection, while still supporting all of Rails' + # migration methods. + def connection + Thread.current[MULTI_THREAD_AR_CONNECTION] || + ActiveRecord::Base.connection + end + + # Starts a thread-pool for N threads, along with N threads each using a + # single connection. The provided block is yielded from inside each + # thread. + # + # Example: + # + # with_multiple_threads(4) do + # execute('SELECT ...') + # end + # + # thread_count - The number of threads to start. + # + # join - When set to true this method will join the threads, blocking the + # caller until all threads have finished running. + # + # Returns an Array containing the started threads. + def with_multiple_threads(thread_count, join: true) + pool = Gitlab::Database.create_connection_pool(thread_count) + + threads = Array.new(thread_count) do + Thread.new do + pool.with_connection do |connection| + begin + Thread.current[MULTI_THREAD_AR_CONNECTION] = connection + yield + ensure + Thread.current[MULTI_THREAD_AR_CONNECTION] = nil + end + end + end + end + + threads.each(&:join) if join + + threads + end + end + end +end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 41ab73abb56..d7dac9f6149 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -45,13 +45,15 @@ module Gitlab # Default branch in the repository def root_ref - @root_ref ||= Gitlab::GitalyClient.migrate(:root_ref) do |is_enabled| - if is_enabled - gitaly_ref_client.default_branch_name - else - discover_default_branch - end - end + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved + # @root_ref ||= Gitlab::GitalyClient.migrate(:root_ref) do |is_enabled| + # if is_enabled + # gitaly_ref_client.default_branch_name + # else + @root_ref ||= discover_default_branch + # end + # end rescue GRPC::BadStatus => e raise CommandError.new(e) end @@ -70,13 +72,15 @@ module Gitlab # Returns an Array of branch names # sorted by name ASC def branch_names - Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled| - if is_enabled - gitaly_ref_client.branch_names - else - branches.map(&:name) - end - end + # Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled| + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved + # if is_enabled + # gitaly_ref_client.branch_names + # else + branches.map(&:name) + # end + # end rescue GRPC::BadStatus => e raise CommandError.new(e) end @@ -131,13 +135,15 @@ module Gitlab # Returns an Array of tag names def tag_names - Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled| - if is_enabled - gitaly_ref_client.tag_names - else - rugged.tags.map { |t| t.name } - end - end + # Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled| + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved + # if is_enabled + # gitaly_ref_client.tag_names + # else + rugged.tags.map { |t| t.name } + # end + # end rescue GRPC::BadStatus => e raise CommandError.new(e) end @@ -458,17 +464,19 @@ module Gitlab # Returns a RefName for a given SHA def ref_name_for_sha(ref_path, sha) - Gitlab::GitalyClient.migrate(:find_ref_name) do |is_enabled| - if is_enabled - gitaly_ref_client.find_ref_name(sha, ref_path) - else - args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha}) - - # Not found -> ["", 0] - # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0] - Gitlab::Popen.popen(args, @path).first.split.last - end - end + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitaly/issues/180 is resolved + # Gitlab::GitalyClient.migrate(:find_ref_name) do |is_enabled| + # if is_enabled + # gitaly_ref_client.find_ref_name(sha, ref_path) + # else + args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha}) + + # Not found -> ["", 0] + # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0] + Gitlab::Popen.popen(args, @path).first.split.last + # end + # end end # Returns commits collection diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb index f98481c6d3a..6e42d8941fb 100644 --- a/lib/gitlab/o_auth/user.rb +++ b/lib/gitlab/o_auth/user.rb @@ -148,7 +148,7 @@ module Gitlab def build_new_user user_params = user_attributes.merge(extern_uid: auth_hash.uid, provider: auth_hash.provider, skip_confirmation: true) - Users::CreateService.new(nil, user_params).build + Users::BuildService.new(nil, user_params).execute end def user_attributes diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index a9a48f7188f..f41c73154f5 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -431,8 +431,7 @@ namespace :gitlab do def check_repo_base_user_and_group gitlab_shell_ssh_user = Gitlab.config.gitlab_shell.ssh_user - gitlab_shell_owner_group = Gitlab.config.gitlab_shell.owner_group - puts "Repo paths owned by #{gitlab_shell_ssh_user}:#{gitlab_shell_owner_group}?" + puts "Repo paths owned by #{gitlab_shell_ssh_user}:root, or #{gitlab_shell_ssh_user}:#{Gitlab.config.gitlab_shell.owner_group}?" Gitlab.config.repositories.storages.each do |name, repository_storage| repo_base_path = repository_storage['path'] @@ -443,15 +442,16 @@ namespace :gitlab do break end - uid = uid_for(gitlab_shell_ssh_user) - gid = gid_for(gitlab_shell_owner_group) - if File.stat(repo_base_path).uid == uid && File.stat(repo_base_path).gid == gid + user_id = uid_for(gitlab_shell_ssh_user) + root_group_id = gid_for('root') + group_ids = [root_group_id, gid_for(Gitlab.config.gitlab_shell.owner_group)] + if File.stat(repo_base_path).uid == user_id && group_ids.include?(File.stat(repo_base_path).gid) puts "yes".color(:green) else puts "no".color(:red) - puts " User id for #{gitlab_shell_ssh_user}: #{uid}. Groupd id for #{gitlab_shell_owner_group}: #{gid}".color(:blue) + puts " User id for #{gitlab_shell_ssh_user}: #{user_id}. Groupd id for root: #{root_group_id}".color(:blue) try_fixing_it( - "sudo chown -R #{gitlab_shell_ssh_user}:#{gitlab_shell_owner_group} #{repo_base_path}" + "sudo chown -R #{gitlab_shell_ssh_user}:root #{repo_base_path}" ) for_more_information( see_installation_guide_section "GitLab Shell" diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake index 9f6cfe3957c..8079c6e416c 100644 --- a/lib/tasks/gitlab/gitaly.rake +++ b/lib/tasks/gitlab/gitaly.rake @@ -7,10 +7,10 @@ namespace :gitlab do abort %(Please specify the directory where you want to install gitaly:\n rake "gitlab:gitaly:install[/home/git/gitaly]") end - tag = "v#{Gitlab::GitalyClient.expected_server_version}" + version = Gitlab::GitalyClient.expected_server_version repo = 'https://gitlab.com/gitlab-org/gitaly.git' - checkout_or_clone_tag(tag: tag, repo: repo, target_dir: args.dir) + checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir) _, status = Gitlab::Popen.popen(%w[which gmake]) command = status.zero? ? 'gmake' : 'make' diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index dd2fda54e62..95687066819 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -1,19 +1,18 @@ namespace :gitlab do namespace :shell do desc "GitLab | Install or upgrade gitlab-shell" - task :install, [:tag, :repo] => :environment do |t, args| + task :install, [:repo] => :environment do |t, args| warn_user_is_not_gitlab default_version = Gitlab::Shell.version_required - default_version_tag = "v#{default_version}" - args.with_defaults(tag: default_version_tag, repo: 'https://gitlab.com/gitlab-org/gitlab-shell.git') + args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitlab-shell.git') gitlab_url = Gitlab.config.gitlab.url # gitlab-shell requires a / at the end of the url gitlab_url += '/' unless gitlab_url.end_with?('/') target_dir = Gitlab.config.gitlab_shell.path - checkout_or_clone_tag(tag: default_version_tag, repo: args.repo, target_dir: target_dir) + checkout_or_clone_version(version: default_version, repo: args.repo, target_dir: target_dir) # Make sure we're on the right tag Dir.chdir(target_dir) do diff --git a/lib/tasks/gitlab/task_helpers.rb b/lib/tasks/gitlab/task_helpers.rb index cdba2262bc2..e3c9d3b491c 100644 --- a/lib/tasks/gitlab/task_helpers.rb +++ b/lib/tasks/gitlab/task_helpers.rb @@ -147,41 +147,30 @@ module Gitlab Rails.env.test? ? Rails.root.join('tmp/tests') : Gitlab.config.gitlab.user_home end - def checkout_or_clone_tag(tag:, repo:, target_dir:) - if Dir.exist?(target_dir) - checkout_tag(tag, target_dir) - else - clone_repo(repo, target_dir) - end + def checkout_or_clone_version(version:, repo:, target_dir:) + version = + if version.starts_with?("=") + version.sub(/\A=/, '') # tag or branch + else + "v#{version}" # tag + end - reset_to_tag(tag, target_dir) + clone_repo(repo, target_dir) unless Dir.exist?(target_dir) + checkout_version(version, target_dir) + reset_to_version(version, target_dir) end def clone_repo(repo, target_dir) run_command!(%W[#{Gitlab.config.git.bin_path} clone -- #{repo} #{target_dir}]) end - def checkout_tag(tag, target_dir) - run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} fetch --tags --quiet]) - run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} checkout --quiet #{tag}]) + def checkout_version(version, target_dir) + run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} fetch --quiet]) + run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} checkout --quiet #{version}]) end - def reset_to_tag(tag_wanted, target_dir) - tag = - begin - # First try to checkout without fetching - # to avoid stalling tests if the Internet is down. - run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} describe -- #{tag_wanted}]) - rescue Gitlab::TaskFailedError - run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} fetch origin]) - run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} describe -- origin/#{tag_wanted}]) - end - - if tag - run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} reset --hard #{tag.strip}]) - else - raise Gitlab::TaskFailedError - end + def reset_to_version(version, target_dir) + run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} reset --hard #{version}]) end end end diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake index dbdfb335a5c..cb2adc81c9d 100644 --- a/lib/tasks/gitlab/update_templates.rake +++ b/lib/tasks/gitlab/update_templates.rake @@ -44,7 +44,7 @@ namespace :gitlab do ), Template.new( "https://gitlab.com/gitlab-org/gitlab-ci-yml.git", - /(\.{1,2}|LICENSE|Pages|autodeploy|\.gitlab-ci.yml)\z/ + /(\.{1,2}|LICENSE|CONTRIBUTING.md|Pages|autodeploy|\.gitlab-ci.yml)\z/ ) ].freeze diff --git a/lib/tasks/gitlab/workhorse.rake b/lib/tasks/gitlab/workhorse.rake index baea94bf8ca..a00b02188cf 100644 --- a/lib/tasks/gitlab/workhorse.rake +++ b/lib/tasks/gitlab/workhorse.rake @@ -7,10 +7,10 @@ namespace :gitlab do abort %(Please specify the directory where you want to install gitlab-workhorse:\n rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]") end - tag = "v#{Gitlab::Workhorse.version}" + version = Gitlab::Workhorse.version repo = 'https://gitlab.com/gitlab-org/gitlab-workhorse.git' - checkout_or_clone_tag(tag: tag, repo: repo, target_dir: args.dir) + checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir) _, status = Gitlab::Popen.popen(%w[which gmake]) command = status.zero? ? 'gmake' : 'make' diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb index 45db7a92fa4..7ce4e9009f5 100644 --- a/qa/qa/page/main/menu.rb +++ b/qa/qa/page/main/menu.rb @@ -11,7 +11,7 @@ module QA end def go_to_admin_area - within_user_menu { click_link 'Admin Area' } + within_user_menu { click_link 'Admin area' } end def sign_out diff --git a/spec/features/discussion_comments/commit_spec.rb b/spec/features/discussion_comments/commit_spec.rb new file mode 100644 index 00000000000..96e0b78f6b9 --- /dev/null +++ b/spec/features/discussion_comments/commit_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'Discussion Comments Merge Request', :feature, :js do + include RepoHelpers + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + project.add_master(user) + login_as(user) + + visit namespace_project_commit_path(project.namespace, project, sample_commit.id) + end + + it_behaves_like 'discussion comments', 'commit' +end diff --git a/spec/features/discussion_comments/issue_spec.rb b/spec/features/discussion_comments/issue_spec.rb new file mode 100644 index 00000000000..ccc9efccd18 --- /dev/null +++ b/spec/features/discussion_comments/issue_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe 'Discussion Comments Issue', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + + before do + project.add_master(user) + login_as(user) + + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it_behaves_like 'discussion comments', 'issue' +end diff --git a/spec/features/discussion_comments/merge_request_spec.rb b/spec/features/discussion_comments/merge_request_spec.rb new file mode 100644 index 00000000000..f99ebeb9cd9 --- /dev/null +++ b/spec/features/discussion_comments/merge_request_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe 'Discussion Comments Merge Request', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + project.add_master(user) + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it_behaves_like 'discussion comments', 'merge request' +end diff --git a/spec/features/discussion_comments/snippets_spec.rb b/spec/features/discussion_comments/snippets_spec.rb new file mode 100644 index 00000000000..19a306511b2 --- /dev/null +++ b/spec/features/discussion_comments/snippets_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe 'Discussion Comments Issue', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:snippet) { create(:project_snippet, :private, project: project, author: user) } + + before do + project.add_master(user) + login_as(user) + + visit namespace_project_snippet_path(project.namespace, project, snippet) + end + + it_behaves_like 'discussion comments', 'snippet' +end diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 88d28b649a4..0e23c3a8849 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -198,6 +198,8 @@ feature 'Diff notes resolve', feature: true, js: true do it 'does not mark discussion as resolved when resolving single note' do page.first '.diff-content .note' do first('.line-resolve-btn').click + + expect(page).to have_selector('.note-action-button .loading') expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") end diff --git a/spec/fixtures/trace/ansi-sequence-and-unicode b/spec/fixtures/trace/ansi-sequence-and-unicode new file mode 100644 index 00000000000..5d2466f0d0f --- /dev/null +++ b/spec/fixtures/trace/ansi-sequence-and-unicode @@ -0,0 +1,5 @@ +[0m[01;34m.[0m +[30;42m..[0m +😺 +ヾ(´༎ຶД༎ຶ`)ノ +[01;32m許功蓋[0m diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index fddeaaf504d..47d904b865b 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -7,6 +7,7 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, namespace: namespace, path: 'merge-requests-project') } let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') } + let(:merged_merge_request) { create(:merge_request, :merged, source_project: project, target_project: project) } let(:pipeline) do create( :ci_pipeline, @@ -32,6 +33,12 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merge_request) end + it 'merge_requests/merged_merge_request.html.raw' do |example| + allow_any_instance_of(MergeRequest).to receive(:source_branch_exists?).and_return(true) + allow_any_instance_of(MergeRequest).to receive(:can_remove_source_branch?).and_return(true) + render_merge_request(example.description, merged_merge_request) + end + private def render_merge_request(fixture_file_name, merge_request) diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index aabc8bea12f..9a2570ef7e9 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,18 +1,17 @@ -/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ +/* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ import Issue from '~/issue'; require('~/lib/utils/text_utility'); describe('Issue', function() { - var INVALID_URL = 'http://goesnowhere.nothing/whereami'; - var $boxClosed, $boxOpen, $btnClose, $btnReopen; + let $boxClosed, $boxOpen, $btnClose, $btnReopen; preloadFixtures('issues/closed-issue.html.raw'); preloadFixtures('issues/issue-with-task-list.html.raw'); preloadFixtures('issues/open-issue.html.raw'); function expectErrorMessage() { - var $flashMessage = $('div.flash-alert'); + const $flashMessage = $('div.flash-alert'); expect($flashMessage).toExist(); expect($flashMessage).toBeVisible(); expect($flashMessage).toHaveText('Unable to update this issue at this time.'); @@ -26,10 +25,28 @@ describe('Issue', function() { expectVisibility($btnReopen, !isIssueOpen); } - function expectPendingRequest(req, $triggeredButton) { - expect(req.type).toBe('PUT'); - expect(req.url).toBe($triggeredButton.attr('href')); - expect($triggeredButton).toHaveProp('disabled', true); + function expectNewBranchButtonState(isPending, canCreate) { + if (Issue.$btnNewBranch.length === 0) { + return; + } + + const $available = Issue.$btnNewBranch.find('.available'); + expect($available).toHaveText('New branch'); + + if (!isPending && canCreate) { + expect($available).toBeVisible(); + } else { + expect($available).toBeHidden(); + } + + const $unavailable = Issue.$btnNewBranch.find('.unavailable'); + expect($unavailable).toHaveText('New branch unavailable'); + + if (!isPending && !canCreate) { + expect($unavailable).toBeVisible(); + } else { + expect($unavailable).toBeHidden(); + } } function expectVisibility($element, shouldBeVisible) { @@ -81,100 +98,107 @@ describe('Issue', function() { }); }); - describe('close issue', function() { - beforeEach(function() { - loadFixtures('issues/open-issue.html.raw'); - findElements(); - this.issue = new Issue(); - - expectIssueState(true); - }); + [true, false].forEach((isIssueInitiallyOpen) => { + describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, function() { + const action = isIssueInitiallyOpen ? 'close' : 'reopen'; + + function ajaxSpy(req) { + if (req.url === this.$triggeredButton.attr('href')) { + expect(req.type).toBe('PUT'); + expect(this.$triggeredButton).toHaveProp('disabled', true); + expectNewBranchButtonState(true, false); + return this.issueStateDeferred; + } else if (req.url === Issue.$btnNewBranch.data('path')) { + expect(req.type).toBe('get'); + expectNewBranchButtonState(true, false); + return this.canCreateBranchDeferred; + } + + expect(req.url).toBe('unexpected'); + return null; + } + + beforeEach(function() { + if (isIssueInitiallyOpen) { + loadFixtures('issues/open-issue.html.raw'); + } else { + loadFixtures('issues/closed-issue.html.raw'); + } + + findElements(); + this.issue = new Issue(); + expectIssueState(isIssueInitiallyOpen); + this.$triggeredButton = isIssueInitiallyOpen ? $btnClose : $btnReopen; + + this.$projectIssuesCounter = $('.issue_counter'); + this.$projectIssuesCounter.text('1,001'); + + this.issueStateDeferred = new jQuery.Deferred(); + this.canCreateBranchDeferred = new jQuery.Deferred(); + + spyOn(jQuery, 'ajax').and.callFake(ajaxSpy.bind(this)); + }); - it('closes an issue', function() { - spyOn(jQuery, 'ajax').and.callFake(function(req) { - expectPendingRequest(req, $btnClose); - req.success({ + it(`${action}s the issue`, function() { + this.$triggeredButton.trigger('click'); + this.issueStateDeferred.resolve({ id: 34 }); - }); - - $btnClose.trigger('click'); + this.canCreateBranchDeferred.resolve({ + can_create_branch: !isIssueInitiallyOpen + }); - expectIssueState(false); - expect($btnClose).toHaveProp('disabled', false); - expect($('.issue_counter')).toHaveText(0); - }); + expectIssueState(!isIssueInitiallyOpen); + expect(this.$triggeredButton).toHaveProp('disabled', false); + expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002'); + expectNewBranchButtonState(false, !isIssueInitiallyOpen); + }); - it('fails to close an issue with success:false', function() { - spyOn(jQuery, 'ajax').and.callFake(function(req) { - expectPendingRequest(req, $btnClose); - req.success({ + it(`fails to ${action} the issue if saved:false`, function() { + this.$triggeredButton.trigger('click'); + this.issueStateDeferred.resolve({ saved: false }); - }); - - $btnClose.attr('href', INVALID_URL); - $btnClose.trigger('click'); - - expectIssueState(true); - expect($btnClose).toHaveProp('disabled', false); - expectErrorMessage(); - expect($('.issue_counter')).toHaveText(1); - }); + this.canCreateBranchDeferred.resolve({ + can_create_branch: isIssueInitiallyOpen + }); - it('fails to closes an issue with HTTP error', function() { - spyOn(jQuery, 'ajax').and.callFake(function(req) { - expectPendingRequest(req, $btnClose); - req.error(); + expectIssueState(isIssueInitiallyOpen); + expect(this.$triggeredButton).toHaveProp('disabled', false); + expectErrorMessage(); + expect(this.$projectIssuesCounter.text()).toBe('1,001'); + expectNewBranchButtonState(false, isIssueInitiallyOpen); }); - $btnClose.attr('href', INVALID_URL); - $btnClose.trigger('click'); - - expectIssueState(true); - expect($btnClose).toHaveProp('disabled', true); - expectErrorMessage(); - expect($('.issue_counter')).toHaveText(1); - }); - - it('updates counter', () => { - spyOn(jQuery, 'ajax').and.callFake(function(req) { - expectPendingRequest(req, $btnClose); - req.success({ - id: 34 + it(`fails to ${action} the issue if HTTP error occurs`, function() { + this.$triggeredButton.trigger('click'); + this.issueStateDeferred.reject(); + this.canCreateBranchDeferred.resolve({ + can_create_branch: isIssueInitiallyOpen }); - }); - expect($('.issue_counter')).toHaveText(1); - $('.issue_counter').text('1,001'); - expect($('.issue_counter').text()).toEqual('1,001'); - $btnClose.trigger('click'); - expect($('.issue_counter').text()).toEqual('1,000'); - }); - }); + expectIssueState(isIssueInitiallyOpen); + expect(this.$triggeredButton).toHaveProp('disabled', true); + expectErrorMessage(); + expect(this.$projectIssuesCounter.text()).toBe('1,001'); + expectNewBranchButtonState(false, isIssueInitiallyOpen); + }); - describe('reopen issue', function() { - beforeEach(function() { - loadFixtures('issues/closed-issue.html.raw'); - findElements(); - this.issue = new Issue(); + it('disables the new branch button if Ajax call fails', function() { + this.$triggeredButton.trigger('click'); + this.issueStateDeferred.reject(); + this.canCreateBranchDeferred.reject(); - expectIssueState(false); - }); - - it('reopens an issue', function() { - spyOn(jQuery, 'ajax').and.callFake(function(req) { - expectPendingRequest(req, $btnReopen); - req.success({ - id: 34 - }); + expectNewBranchButtonState(false, false); }); - $btnReopen.trigger('click'); + it('does not trigger Ajax call if new branch button is missing', function() { + Issue.$btnNewBranch = $(); + this.canCreateBranchDeferred = null; - expectIssueState(true); - expect($btnReopen).toHaveProp('disabled', false); - expect($('.issue_counter')).toHaveText(1); + this.$triggeredButton.trigger('click'); + this.issueStateDeferred.reject(); + }); }); }); }); diff --git a/spec/javascripts/lib/utils/poll_spec.js b/spec/javascripts/lib/utils/poll_spec.js index e3429c2a1cb..918b6d32c43 100644 --- a/spec/javascripts/lib/utils/poll_spec.js +++ b/spec/javascripts/lib/utils/poll_spec.js @@ -4,6 +4,20 @@ import Poll from '~/lib/utils/poll'; Vue.use(VueResource); +const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { + const timer = () => { + setTimeout(() => { + if (service.fetch.calls.count() === waitForCount) { + successCallback(); + } else { + timer(); + } + }, 5); + }; + + timer(); +}; + class ServiceMock { constructor(endpoint) { this.service = Vue.resource(endpoint); @@ -16,6 +30,7 @@ class ServiceMock { describe('Poll', () => { let callbacks; + let service; beforeEach(() => { callbacks = { @@ -23,8 +38,11 @@ describe('Poll', () => { error: () => {}, }; + service = new ServiceMock('endpoint'); + spyOn(callbacks, 'success'); spyOn(callbacks, 'error'); + spyOn(service, 'fetch').and.callThrough(); }); it('calls the success callback when no header for interval is provided', (done) => { @@ -35,19 +53,20 @@ describe('Poll', () => { Vue.http.interceptors.push(successInterceptor); new Poll({ - resource: new ServiceMock('endpoint'), + resource: service, method: 'fetch', successCallback: callbacks.success, errorCallback: callbacks.error, }).makeRequest(); - setTimeout(() => { + waitForAllCallsToFinish(service, 1, () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); + + Vue.http.interceptors = _.without(Vue.http.interceptors, successInterceptor); + done(); }, 0); - - Vue.http.interceptors = _.without(Vue.http.interceptors, successInterceptor); }); it('calls the error callback whe the http request returns an error', (done) => { @@ -58,19 +77,19 @@ describe('Poll', () => { Vue.http.interceptors.push(errorInterceptor); new Poll({ - resource: new ServiceMock('endpoint'), + resource: service, method: 'fetch', successCallback: callbacks.success, errorCallback: callbacks.error, }).makeRequest(); - setTimeout(() => { + waitForAllCallsToFinish(service, 1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).toHaveBeenCalled(); - done(); - }, 0); + Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor); - Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor); + done(); + }); }); it('should call the success callback when the interval header is -1', (done) => { @@ -81,7 +100,7 @@ describe('Poll', () => { Vue.http.interceptors.push(intervalInterceptor); new Poll({ - resource: new ServiceMock('endpoint'), + resource: service, method: 'fetch', successCallback: callbacks.success, errorCallback: callbacks.error, @@ -90,10 +109,11 @@ describe('Poll', () => { setTimeout(() => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); + + Vue.http.interceptors = _.without(Vue.http.interceptors, intervalInterceptor); + done(); }, 0); - - Vue.http.interceptors = _.without(Vue.http.interceptors, intervalInterceptor); }); it('starts polling when http status is 200 and interval header is provided', (done) => { @@ -103,26 +123,28 @@ describe('Poll', () => { Vue.http.interceptors.push(pollInterceptor); - const service = new ServiceMock('endpoint'); - spyOn(service, 'fetch').and.callThrough(); - - new Poll({ + const Polling = new Poll({ resource: service, method: 'fetch', data: { page: 1 }, successCallback: callbacks.success, errorCallback: callbacks.error, - }).makeRequest(); + }); + + Polling.makeRequest(); + + waitForAllCallsToFinish(service, 2, () => { + Polling.stop(); - setTimeout(() => { expect(service.fetch.calls.count()).toEqual(2); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - done(); - }, 5); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); + Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); + + done(); + }); }); describe('stop', () => { @@ -133,9 +155,6 @@ describe('Poll', () => { Vue.http.interceptors.push(pollInterceptor); - const service = new ServiceMock('endpoint'); - spyOn(service, 'fetch').and.callThrough(); - const Polling = new Poll({ resource: service, method: 'fetch', @@ -150,14 +169,15 @@ describe('Poll', () => { Polling.makeRequest(); - setTimeout(() => { + waitForAllCallsToFinish(service, 1, () => { expect(service.fetch.calls.count()).toEqual(1); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - done(); - }, 100); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); + Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); + + done(); + }); }); }); @@ -169,10 +189,6 @@ describe('Poll', () => { Vue.http.interceptors.push(pollInterceptor); - const service = new ServiceMock('endpoint'); - - spyOn(service, 'fetch').and.callThrough(); - const Polling = new Poll({ resource: service, method: 'fetch', @@ -187,17 +203,22 @@ describe('Poll', () => { }); spyOn(Polling, 'stop').and.callThrough(); + spyOn(Polling, 'restart').and.callThrough(); Polling.makeRequest(); - setTimeout(() => { + waitForAllCallsToFinish(service, 2, () => { + Polling.stop(); + expect(service.fetch.calls.count()).toEqual(2); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - done(); - }, 10); + expect(Polling.restart).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); + Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); + + done(); + }); }); }); }); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js index 4200e943121..daef9b93fa5 100644 --- a/spec/javascripts/lib/utils/text_utility_spec.js +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -1,110 +1,108 @@ require('~/lib/utils/text_utility'); -(() => { - describe('text_utility', () => { - describe('gl.text.getTextWidth', () => { - it('returns zero width when no text is passed', () => { - expect(gl.text.getTextWidth('')).toBe(0); - }); +describe('text_utility', () => { + describe('gl.text.getTextWidth', () => { + it('returns zero width when no text is passed', () => { + expect(gl.text.getTextWidth('')).toBe(0); + }); - it('returns zero width when no text is passed and font is passed', () => { - expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); - }); + it('returns zero width when no text is passed and font is passed', () => { + expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); + }); - it('returns width when text is passed', () => { - expect(gl.text.getTextWidth('foo') > 0).toBe(true); - }); + it('returns width when text is passed', () => { + expect(gl.text.getTextWidth('foo') > 0).toBe(true); + }); - it('returns bigger width when font is larger', () => { - const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); - const regular = gl.text.getTextWidth('foo', '10px sans-serif'); - expect(largeFont > regular).toBe(true); - }); + it('returns bigger width when font is larger', () => { + const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); + const regular = gl.text.getTextWidth('foo', '10px sans-serif'); + expect(largeFont > regular).toBe(true); }); + }); - describe('gl.text.pluralize', () => { - it('returns pluralized', () => { - expect(gl.text.pluralize('test', 2)).toBe('tests'); - }); + describe('gl.text.pluralize', () => { + it('returns pluralized', () => { + expect(gl.text.pluralize('test', 2)).toBe('tests'); + }); - it('returns pluralized when count is 0', () => { - expect(gl.text.pluralize('test', 0)).toBe('tests'); - }); + it('returns pluralized when count is 0', () => { + expect(gl.text.pluralize('test', 0)).toBe('tests'); + }); - it('does not return pluralized', () => { - expect(gl.text.pluralize('test', 1)).toBe('test'); - }); + it('does not return pluralized', () => { + expect(gl.text.pluralize('test', 1)).toBe('test'); }); + }); - describe('gl.text.highCountTrim', () => { - it('returns 99+ for count >= 100', () => { - expect(gl.text.highCountTrim(105)).toBe('99+'); - expect(gl.text.highCountTrim(100)).toBe('99+'); - }); + describe('gl.text.highCountTrim', () => { + it('returns 99+ for count >= 100', () => { + expect(gl.text.highCountTrim(105)).toBe('99+'); + expect(gl.text.highCountTrim(100)).toBe('99+'); + }); - it('returns exact number for count < 100', () => { - expect(gl.text.highCountTrim(45)).toBe(45); - }); + it('returns exact number for count < 100', () => { + expect(gl.text.highCountTrim(45)).toBe(45); }); + }); - describe('gl.text.insertText', () => { - let textArea; + describe('gl.text.insertText', () => { + let textArea; - beforeAll(() => { - textArea = document.createElement('textarea'); - document.querySelector('body').appendChild(textArea); - }); + beforeAll(() => { + textArea = document.createElement('textarea'); + document.querySelector('body').appendChild(textArea); + }); - afterAll(() => { - textArea.parentNode.removeChild(textArea); - }); + afterAll(() => { + textArea.parentNode.removeChild(textArea); + }); - describe('without selection', () => { - it('inserts the tag on an empty line', () => { - const initialValue = ''; + describe('without selection', () => { + it('inserts the tag on an empty line', () => { + const initialValue = ''; - textArea.value = initialValue; - textArea.selectionStart = 0; - textArea.selectionEnd = 0; + textArea.value = initialValue; + textArea.selectionStart = 0; + textArea.selectionEnd = 0; - gl.text.insertText(textArea, textArea.value, '*', null, '', false); + gl.text.insertText(textArea, textArea.value, '*', null, '', false); - expect(textArea.value).toEqual(`${initialValue}* `); - }); + expect(textArea.value).toEqual(`${initialValue}* `); + }); - it('inserts the tag on a new line if the current one is not empty', () => { - const initialValue = 'some text'; + it('inserts the tag on a new line if the current one is not empty', () => { + const initialValue = 'some text'; - textArea.value = initialValue; - textArea.setSelectionRange(initialValue.length, initialValue.length); + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); - gl.text.insertText(textArea, textArea.value, '*', null, '', false); + gl.text.insertText(textArea, textArea.value, '*', null, '', false); - expect(textArea.value).toEqual(`${initialValue}\n* `); - }); + expect(textArea.value).toEqual(`${initialValue}\n* `); + }); - it('inserts the tag on the same line if the current line only contains spaces', () => { - const initialValue = ' '; + it('inserts the tag on the same line if the current line only contains spaces', () => { + const initialValue = ' '; - textArea.value = initialValue; - textArea.setSelectionRange(initialValue.length, initialValue.length); + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); - gl.text.insertText(textArea, textArea.value, '*', null, '', false); + gl.text.insertText(textArea, textArea.value, '*', null, '', false); - expect(textArea.value).toEqual(`${initialValue}* `); - }); + expect(textArea.value).toEqual(`${initialValue}* `); + }); - it('inserts the tag on the same line if the current line only contains tabs', () => { - const initialValue = '\t\t\t'; + it('inserts the tag on the same line if the current line only contains tabs', () => { + const initialValue = '\t\t\t'; - textArea.value = initialValue; - textArea.setSelectionRange(initialValue.length, initialValue.length); + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); - gl.text.insertText(textArea, textArea.value, '*', null, '', false); + gl.text.insertText(textArea, textArea.value, '*', null, '', false); - expect(textArea.value).toEqual(`${initialValue}* `); - }); + expect(textArea.value).toEqual(`${initialValue}* `); }); }); }); -})(); +}); diff --git a/spec/javascripts/merged_buttons_spec.js b/spec/javascripts/merged_buttons_spec.js new file mode 100644 index 00000000000..b5c5e60dd97 --- /dev/null +++ b/spec/javascripts/merged_buttons_spec.js @@ -0,0 +1,44 @@ +/* global MergedButtons */ + +import '~/merged_buttons'; + +describe('MergedButtons', () => { + const fixturesPath = 'merge_requests/merged_merge_request.html.raw'; + preloadFixtures(fixturesPath); + + beforeEach(() => { + loadFixtures(fixturesPath); + this.mergedButtons = new MergedButtons(); + this.$removeBranchWidget = $('.remove_source_branch_widget:not(.failed)'); + this.$removeBranchProgress = $('.remove_source_branch_in_progress'); + this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); + this.$removeBranchButton = $('.remove_source_branch'); + }); + + describe('removeSourceBranch', () => { + it('shows loader', () => { + $('.remove_source_branch').trigger('click'); + expect(this.$removeBranchProgress).toBeVisible(); + expect(this.$removeBranchWidget).not.toBeVisible(); + }); + }); + + describe('removeBranchSuccess', () => { + it('refreshes page when branch removed', () => { + spyOn(gl.utils, 'refreshCurrentPage').and.stub(); + const response = { status: 200 }; + this.$removeBranchButton.trigger('ajax:success', response, 'xhr'); + expect(gl.utils.refreshCurrentPage).toHaveBeenCalled(); + }); + }); + + describe('removeBranchError', () => { + it('shows error message', () => { + const response = { status: 500 }; + this.$removeBranchButton.trigger('ajax:error', response, 'xhr'); + expect(this.$removeBranchFailed).toBeVisible(); + expect(this.$removeBranchProgress).not.toBeVisible(); + expect(this.$removeBranchWidget).not.toBeVisible(); + }); + }); +}); diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb index 603b79a323c..5cb98163746 100644 --- a/spec/lib/banzai/filter/issuable_state_filter_spec.rb +++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb @@ -6,8 +6,8 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do let(:user) { create(:user) } - def create_link(data) - link_to('text', '', class: 'gfm has-tooltip', data: data) + def create_link(text, data) + link_to(text, '', class: 'gfm has-tooltip', data: data) end it 'ignores non-GFM links' do @@ -19,16 +19,37 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'ignores non-issuable links' do project = create(:empty_project, :public) - link = create_link(project: project, reference_type: 'issue') + link = create_link('text', project: project, reference_type: 'issue') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text') end + it 'ignores issuable links with empty content' do + issue = create(:issue, :closed) + link = create_link('', issue: issue.id, reference_type: 'issue') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('') + end + + it 'adds text with standard formatting' do + issue = create(:issue, :closed) + link = create_link( + 'something <strong>else</strong>'.html_safe, + issue: issue.id, + reference_type: 'issue' + ) + doc = filter(link, current_user: user) + + expect(doc.css('a').last.inner_html). + to eq('something <strong>else</strong> [closed]') + end + context 'for issue references' do it 'ignores open issue references' do issue = create(:issue) - link = create_link(issue: issue.id, reference_type: 'issue') + link = create_link('text', issue: issue.id, reference_type: 'issue') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text') @@ -36,7 +57,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'ignores reopened issue references' do reopened_issue = create(:issue, :reopened) - link = create_link(issue: reopened_issue.id, reference_type: 'issue') + link = create_link('text', issue: reopened_issue.id, reference_type: 'issue') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text') @@ -44,7 +65,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'appends [closed] to closed issue references' do closed_issue = create(:issue, :closed) - link = create_link(issue: closed_issue.id, reference_type: 'issue') + link = create_link('text', issue: closed_issue.id, reference_type: 'issue') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text [closed]') @@ -54,7 +75,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do context 'for merge request references' do it 'ignores open merge request references' do mr = create(:merge_request) - link = create_link(merge_request: mr.id, reference_type: 'merge_request') + link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text') @@ -62,7 +83,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'ignores reopened merge request references' do mr = create(:merge_request, :reopened) - link = create_link(merge_request: mr.id, reference_type: 'merge_request') + link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text') @@ -70,7 +91,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'ignores locked merge request references' do mr = create(:merge_request, :locked) - link = create_link(merge_request: mr.id, reference_type: 'merge_request') + link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text') @@ -78,7 +99,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'appends [closed] to closed merge request references' do mr = create(:merge_request, :closed) - link = create_link(merge_request: mr.id, reference_type: 'merge_request') + link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text [closed]') @@ -86,7 +107,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'appends [merged] to merged merge request references' do mr = create(:merge_request, :merged) - link = create_link(merge_request: mr.id, reference_type: 'merge_request') + link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') doc = filter(link, current_user: user) expect(doc.css('a').last.text).to eq('text [merged]') diff --git a/spec/lib/container_registry/path_spec.rb b/spec/lib/container_registry/path_spec.rb index b9c4572c269..f3b3a9a715f 100644 --- a/spec/lib/container_registry/path_spec.rb +++ b/spec/lib/container_registry/path_spec.rb @@ -33,10 +33,20 @@ describe ContainerRegistry::Path do end describe '#to_s' do - let(:path) { 'some/image' } + context 'when path does not have uppercase characters' do + let(:path) { 'some/image' } - it 'return a string with a repository path' do - expect(subject.to_s).to eq path + it 'return a string with a repository path' do + expect(subject.to_s).to eq 'some/image' + end + end + + context 'when path has uppercase characters' do + let(:path) { 'SoMe/ImAgE' } + + it 'return a string with a repository path' do + expect(subject.to_s).to eq 'some/image' + end end end @@ -70,6 +80,12 @@ describe ContainerRegistry::Path do it { is_expected.to be_valid } end + + context 'when path contains uppercase letters' do + let(:path) { 'Some/Registry' } + + it { is_expected.to be_valid } + end end describe '#has_repository?' do diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb index f1a1a71c528..9e3bd6d662f 100644 --- a/spec/lib/gitlab/ci/trace/stream_spec.rb +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -17,12 +17,12 @@ describe Gitlab::Ci::Trace::Stream do describe '#limit' do let(:stream) do described_class.new do - StringIO.new("12345678") + StringIO.new((1..8).to_a.join("\n")) end end - it 'if size is larger we start from beggining' do - stream.limit(10) + it 'if size is larger we start from beginning' do + stream.limit(20) expect(stream.tell).to eq(0) end @@ -30,7 +30,27 @@ describe Gitlab::Ci::Trace::Stream do it 'if size is smaller we start from the end' do stream.limit(2) - expect(stream.tell).to eq(6) + expect(stream.raw).to eq("8") + end + + context 'when the trace contains ANSI sequence and Unicode' do + let(:stream) do + described_class.new do + File.open(expand_fixture_path('trace/ansi-sequence-and-unicode')) + end + end + + it 'forwards to the next linefeed, case 1' do + stream.limit(7) + + expect(stream.raw).to eq('') + end + + it 'forwards to the next linefeed, case 2' do + stream.limit(29) + + expect(stream.raw).to eq("\e[01;32m許功蓋\e[0m\n") + end end end @@ -167,7 +187,7 @@ describe Gitlab::Ci::Trace::Stream do let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered' } let(:regex) { '\(\d+.\d+\%\) covered' } - it { is_expected.to eq(98.29) } + it { is_expected.to eq("98.29") } end context 'valid content & bad regex' do @@ -188,14 +208,14 @@ describe Gitlab::Ci::Trace::Stream do let(:data) { ' (98.39%) covered. (98.29%) covered' } let(:regex) { '\(\d+.\d+\%\) covered' } - it { is_expected.to eq(98.29) } + it { is_expected.to eq("98.29") } end context 'using a regex capture' do let(:data) { 'TOTAL 9926 3489 65%' } let(:regex) { 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)' } - it { is_expected.to eq(65) } + it { is_expected.to eq("65") } end end end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 69e8dc9220d..9cb0b62590a 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -40,12 +40,24 @@ describe Gitlab::Ci::Trace do describe '#extract_coverage' do let(:regex) { '\(\d+.\d+\%\) covered' } - before do - trace.set('Coverage 1033 / 1051 LOC (98.29%) covered') + context 'matching coverage' do + before do + trace.set('Coverage 1033 / 1051 LOC (98.29%) covered') + end + + it "returns valid coverage" do + expect(trace.extract_coverage(regex)).to eq("98.29") + end end - it "returns valid coverage" do - expect(trace.extract_coverage(regex)).to eq(98.29) + context 'no coverage' do + before do + trace.set('No coverage') + end + + it 'returs nil' do + expect(trace.extract_coverage(regex)).to be_nil + end end end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 4ac79454647..a044b871730 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -175,6 +175,50 @@ describe Gitlab::Database::MigrationHelpers, lib: true do end end + describe '#true_value' do + context 'using PostgreSQL' do + before do + expect(Gitlab::Database).to receive(:postgresql?).and_return(true) + end + + it 'returns the appropriate value' do + expect(model.true_value).to eq("'t'") + end + end + + context 'using MySQL' do + before do + expect(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + + it 'returns the appropriate value' do + expect(model.true_value).to eq(1) + end + end + end + + describe '#false_value' do + context 'using PostgreSQL' do + before do + expect(Gitlab::Database).to receive(:postgresql?).and_return(true) + end + + it 'returns the appropriate value' do + expect(model.false_value).to eq("'f'") + end + end + + context 'using MySQL' do + before do + expect(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + + it 'returns the appropriate value' do + expect(model.false_value).to eq(0) + end + end + end + describe '#update_column_in_batches' do before do create_list(:empty_project, 5) @@ -294,4 +338,392 @@ describe Gitlab::Database::MigrationHelpers, lib: true do end end end + + describe '#rename_column_concurrently' do + context 'in a transaction' do + it 'raises RuntimeError' do + allow(model).to receive(:transaction_open?).and_return(true) + + expect { model.rename_column_concurrently(:users, :old, :new) }. + to raise_error(RuntimeError) + end + end + + context 'outside a transaction' do + let(:old_column) do + double(:column, + type: :integer, + limit: 8, + default: 0, + null: false, + precision: 5, + scale: 1) + end + + let(:trigger_name) { model.rename_trigger_name(:users, :old, :new) } + + before do + allow(model).to receive(:transaction_open?).and_return(false) + allow(model).to receive(:column_for).and_return(old_column) + + # Since MySQL and PostgreSQL use different quoting styles we'll just + # stub the methods used for this to make testing easier. + allow(model).to receive(:quote_column_name) { |name| name.to_s } + allow(model).to receive(:quote_table_name) { |name| name.to_s } + end + + context 'using MySQL' do + it 'renames a column concurrently' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + + expect(model).to receive(:install_rename_triggers_for_mysql). + with(trigger_name, 'users', 'old', 'new') + + expect(model).to receive(:add_column). + with(:users, :new, :integer, + limit: old_column.limit, + default: old_column.default, + null: old_column.null, + precision: old_column.precision, + scale: old_column.scale) + + expect(model).to receive(:update_column_in_batches) + + expect(model).to receive(:copy_indexes).with(:users, :old, :new) + expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new) + + model.rename_column_concurrently(:users, :old, :new) + end + end + + context 'using PostgreSQL' do + it 'renames a column concurrently' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + + expect(model).to receive(:install_rename_triggers_for_postgresql). + with(trigger_name, 'users', 'old', 'new') + + expect(model).to receive(:add_column). + with(:users, :new, :integer, + limit: old_column.limit, + default: old_column.default, + null: old_column.null, + precision: old_column.precision, + scale: old_column.scale) + + expect(model).to receive(:update_column_in_batches) + + expect(model).to receive(:copy_indexes).with(:users, :old, :new) + expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new) + + model.rename_column_concurrently(:users, :old, :new) + end + end + end + end + + describe '#cleanup_concurrent_column_rename' do + it 'cleans up the renaming procedure for PostgreSQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + + expect(model).to receive(:remove_rename_triggers_for_postgresql). + with(:users, /trigger_.{12}/) + + expect(model).to receive(:remove_column).with(:users, :old) + + model.cleanup_concurrent_column_rename(:users, :old, :new) + end + + it 'cleans up the renaming procedure for MySQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + + expect(model).to receive(:remove_rename_triggers_for_mysql). + with(/trigger_.{12}/) + + expect(model).to receive(:remove_column).with(:users, :old) + + model.cleanup_concurrent_column_rename(:users, :old, :new) + end + end + + describe '#change_column_type_concurrently' do + it 'changes the column type' do + expect(model).to receive(:rename_column_concurrently). + with('users', 'username', 'username_for_type_change', type: :text) + + model.change_column_type_concurrently('users', 'username', :text) + end + end + + describe '#cleanup_concurrent_column_type_change' do + it 'cleans up the type changing procedure' do + expect(model).to receive(:cleanup_concurrent_column_rename). + with('users', 'username', 'username_for_type_change') + + expect(model).to receive(:rename_column). + with('users', 'username_for_type_change', 'username') + + model.cleanup_concurrent_column_type_change('users', 'username') + end + end + + describe '#install_rename_triggers_for_postgresql' do + it 'installs the triggers for PostgreSQL' do + expect(model).to receive(:execute). + with(/CREATE OR REPLACE FUNCTION foo()/m) + + expect(model).to receive(:execute). + with(/CREATE TRIGGER foo/m) + + model.install_rename_triggers_for_postgresql('foo', :users, :old, :new) + end + end + + describe '#install_rename_triggers_for_mysql' do + it 'installs the triggers for MySQL' do + expect(model).to receive(:execute). + with(/CREATE TRIGGER foo_insert.+ON users/m) + + expect(model).to receive(:execute). + with(/CREATE TRIGGER foo_update.+ON users/m) + + model.install_rename_triggers_for_mysql('foo', :users, :old, :new) + end + end + + describe '#remove_rename_triggers_for_postgresql' do + it 'removes the function and trigger' do + expect(model).to receive(:execute).with('DROP TRIGGER foo ON bar') + expect(model).to receive(:execute).with('DROP FUNCTION foo()') + + model.remove_rename_triggers_for_postgresql('bar', 'foo') + end + end + + describe '#remove_rename_triggers_for_mysql' do + it 'removes the triggers' do + expect(model).to receive(:execute).with('DROP TRIGGER foo_insert') + expect(model).to receive(:execute).with('DROP TRIGGER foo_update') + + model.remove_rename_triggers_for_mysql('foo') + end + end + + describe '#rename_trigger_name' do + it 'returns a String' do + expect(model.rename_trigger_name(:users, :foo, :bar)). + to match(/trigger_.{12}/) + end + end + + describe '#indexes_for' do + it 'returns the indexes for a column' do + idx1 = double(:idx, columns: %w(project_id)) + idx2 = double(:idx, columns: %w(user_id)) + + allow(model).to receive(:indexes).with('table').and_return([idx1, idx2]) + + expect(model.indexes_for('table', :user_id)).to eq([idx2]) + end + end + + describe '#foreign_keys_for' do + it 'returns the foreign keys for a column' do + fk1 = double(:fk, column: 'project_id') + fk2 = double(:fk, column: 'user_id') + + allow(model).to receive(:foreign_keys).with('table').and_return([fk1, fk2]) + + expect(model.foreign_keys_for('table', :user_id)).to eq([fk2]) + end + end + + describe '#copy_indexes' do + context 'using a regular index using a single column' do + it 'copies the index' do + index = double(:index, + columns: %w(project_id), + name: 'index_on_issues_project_id', + using: nil, + where: nil, + opclasses: {}, + unique: false, + lengths: [], + orders: []) + + allow(model).to receive(:indexes_for).with(:issues, 'project_id'). + and_return([index]) + + expect(model).to receive(:add_concurrent_index). + with(:issues, + %w(gl_project_id), + unique: false, + name: 'index_on_issues_gl_project_id', + length: [], + order: []) + + model.copy_indexes(:issues, :project_id, :gl_project_id) + end + end + + context 'using a regular index with multiple columns' do + it 'copies the index' do + index = double(:index, + columns: %w(project_id foobar), + name: 'index_on_issues_project_id_foobar', + using: nil, + where: nil, + opclasses: {}, + unique: false, + lengths: [], + orders: []) + + allow(model).to receive(:indexes_for).with(:issues, 'project_id'). + and_return([index]) + + expect(model).to receive(:add_concurrent_index). + with(:issues, + %w(gl_project_id foobar), + unique: false, + name: 'index_on_issues_gl_project_id_foobar', + length: [], + order: []) + + model.copy_indexes(:issues, :project_id, :gl_project_id) + end + end + + context 'using an index with a WHERE clause' do + it 'copies the index' do + index = double(:index, + columns: %w(project_id), + name: 'index_on_issues_project_id', + using: nil, + where: 'foo', + opclasses: {}, + unique: false, + lengths: [], + orders: []) + + allow(model).to receive(:indexes_for).with(:issues, 'project_id'). + and_return([index]) + + expect(model).to receive(:add_concurrent_index). + with(:issues, + %w(gl_project_id), + unique: false, + name: 'index_on_issues_gl_project_id', + length: [], + order: [], + where: 'foo') + + model.copy_indexes(:issues, :project_id, :gl_project_id) + end + end + + context 'using an index with a USING clause' do + it 'copies the index' do + index = double(:index, + columns: %w(project_id), + name: 'index_on_issues_project_id', + where: nil, + using: 'foo', + opclasses: {}, + unique: false, + lengths: [], + orders: []) + + allow(model).to receive(:indexes_for).with(:issues, 'project_id'). + and_return([index]) + + expect(model).to receive(:add_concurrent_index). + with(:issues, + %w(gl_project_id), + unique: false, + name: 'index_on_issues_gl_project_id', + length: [], + order: [], + using: 'foo') + + model.copy_indexes(:issues, :project_id, :gl_project_id) + end + end + + context 'using an index with custom operator classes' do + it 'copies the index' do + index = double(:index, + columns: %w(project_id), + name: 'index_on_issues_project_id', + using: nil, + where: nil, + opclasses: { 'project_id' => 'bar' }, + unique: false, + lengths: [], + orders: []) + + allow(model).to receive(:indexes_for).with(:issues, 'project_id'). + and_return([index]) + + expect(model).to receive(:add_concurrent_index). + with(:issues, + %w(gl_project_id), + unique: false, + name: 'index_on_issues_gl_project_id', + length: [], + order: [], + opclasses: { 'gl_project_id' => 'bar' }) + + model.copy_indexes(:issues, :project_id, :gl_project_id) + end + end + + describe 'using an index of which the name does not contain the source column' do + it 'raises RuntimeError' do + index = double(:index, + columns: %w(project_id), + name: 'index_foobar_index', + using: nil, + where: nil, + opclasses: {}, + unique: false, + lengths: [], + orders: []) + + allow(model).to receive(:indexes_for).with(:issues, 'project_id'). + and_return([index]) + + expect { model.copy_indexes(:issues, :project_id, :gl_project_id) }. + to raise_error(RuntimeError) + end + end + end + + describe '#copy_foreign_keys' do + it 'copies foreign keys from one column to another' do + fk = double(:fk, + from_table: 'issues', + to_table: 'projects', + on_delete: :cascade) + + allow(model).to receive(:foreign_keys_for).with(:issues, :project_id). + and_return([fk]) + + expect(model).to receive(:add_concurrent_foreign_key). + with('issues', 'projects', column: :gl_project_id, on_delete: :cascade) + + model.copy_foreign_keys(:issues, :project_id, :gl_project_id) + end + end + + describe '#column_for' do + it 'returns a column object for an existing column' do + column = model.column_for(:users, :id) + + expect(column.name).to eq('id') + end + + it 'returns nil when a column does not exist' do + expect(model.column_for(:users, :kittens)).to be_nil + end + end end diff --git a/spec/lib/gitlab/database/multi_threaded_migration_spec.rb b/spec/lib/gitlab/database/multi_threaded_migration_spec.rb new file mode 100644 index 00000000000..6c45f13bb5a --- /dev/null +++ b/spec/lib/gitlab/database/multi_threaded_migration_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Gitlab::Database::MultiThreadedMigration do + let(:migration) do + Class.new { include Gitlab::Database::MultiThreadedMigration }.new + end + + describe '#connection' do + after do + Thread.current[described_class::MULTI_THREAD_AR_CONNECTION] = nil + end + + it 'returns the thread-local connection if present' do + Thread.current[described_class::MULTI_THREAD_AR_CONNECTION] = 10 + + expect(migration.connection).to eq(10) + end + + it 'returns the global connection if no thread-local connection was set' do + expect(migration.connection).to eq(ActiveRecord::Base.connection) + end + end + + describe '#with_multiple_threads' do + it 'starts multiple threads and yields the supplied block in every thread' do + output = Queue.new + + migration.with_multiple_threads(2) do + output << migration.connection.execute('SELECT 1') + end + + expect(output.size).to eq(2) + end + + it 'joins the threads when the join parameter is set' do + expect_any_instance_of(Thread).to receive(:join).and_call_original + + migration.with_multiple_threads(1) { } + end + end +end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 4ce4e6e1034..9b1d66a1b1c 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -150,13 +150,13 @@ describe Gitlab::Database, lib: true do it 'returns correct value for PostgreSQL' do expect(described_class).to receive(:postgresql?).and_return(true) - expect(MigrationTest.new.true_value).to eq "'t'" + expect(described_class.true_value).to eq "'t'" end it 'returns correct value for MySQL' do expect(described_class).to receive(:postgresql?).and_return(false) - expect(MigrationTest.new.true_value).to eq 1 + expect(described_class.true_value).to eq 1 end end @@ -164,13 +164,13 @@ describe Gitlab::Database, lib: true do it 'returns correct value for PostgreSQL' do expect(described_class).to receive(:postgresql?).and_return(true) - expect(MigrationTest.new.false_value).to eq "'f'" + expect(described_class.false_value).to eq "'f'" end it 'returns correct value for MySQL' do expect(described_class).to receive(:postgresql?).and_return(false) - expect(MigrationTest.new.false_value).to eq 0 + expect(described_class.false_value).to eq 0 end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 690f604db5e..3d6d7292b42 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -24,20 +24,21 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - context 'with gitaly enabled' do - before { stub_gitaly } - - it 'gets the branch name from GitalyClient' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name) - repository.root_ref - end - - it 'wraps GRPC exceptions' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name). - and_raise(GRPC::Unknown) - expect { repository.root_ref }.to raise_error(Gitlab::Git::CommandError) - end - end + # TODO: Uncomment when feature is reenabled + # context 'with gitaly enabled' do + # before { stub_gitaly } + # + # it 'gets the branch name from GitalyClient' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name) + # repository.root_ref + # end + # + # it 'wraps GRPC exceptions' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name). + # and_raise(GRPC::Unknown) + # expect { repository.root_ref }.to raise_error(Gitlab::Git::CommandError) + # end + # end end describe "#rugged" do @@ -112,20 +113,21 @@ describe Gitlab::Git::Repository, seed_helper: true do it { is_expected.to include("master") } it { is_expected.not_to include("branch-from-space") } - context 'with gitaly enabled' do - before { stub_gitaly } - - it 'gets the branch names from GitalyClient' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names) - subject - end - - it 'wraps GRPC exceptions' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names). - and_raise(GRPC::Unknown) - expect { subject }.to raise_error(Gitlab::Git::CommandError) - end - end + # TODO: Uncomment when feature is reenabled + # context 'with gitaly enabled' do + # before { stub_gitaly } + # + # it 'gets the branch names from GitalyClient' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names) + # subject + # end + # + # it 'wraps GRPC exceptions' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names). + # and_raise(GRPC::Unknown) + # expect { subject }.to raise_error(Gitlab::Git::CommandError) + # end + # end end describe '#tag_names' do @@ -143,20 +145,21 @@ describe Gitlab::Git::Repository, seed_helper: true do it { is_expected.to include("v1.0.0") } it { is_expected.not_to include("v5.0.0") } - context 'with gitaly enabled' do - before { stub_gitaly } - - it 'gets the tag names from GitalyClient' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names) - subject - end - - it 'wraps GRPC exceptions' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names). - and_raise(GRPC::Unknown) - expect { subject }.to raise_error(Gitlab::Git::CommandError) - end - end + # TODO: Uncomment when feature is reenabled + # context 'with gitaly enabled' do + # before { stub_gitaly } + # + # it 'gets the tag names from GitalyClient' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names) + # subject + # end + # + # it 'wraps GRPC exceptions' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names). + # and_raise(GRPC::Unknown) + # expect { subject }.to raise_error(Gitlab::Git::CommandError) + # end + # end end shared_examples 'archive check' do |extenstion| diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 980a1b70ef5..ce31c8ed94c 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -389,31 +389,32 @@ eos end end - describe '#raw_diffs' do - context 'Gitaly commit_raw_diffs feature enabled' do - before do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) - end - - context 'when a truthy deltas_only is not passed to args' do - it 'fetches diffs from Gitaly server' do - expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). - with(commit) - - commit.raw_diffs - end - end - - context 'when a truthy deltas_only is passed to args' do - it 'fetches diffs using Rugged' do - opts = { deltas_only: true } - - expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) - expect(commit.raw).to receive(:diffs).with(opts) - - commit.raw_diffs(opts) - end - end - end - end + # describe '#raw_diffs' do + # TODO: Uncomment when feature is reenabled + # context 'Gitaly commit_raw_diffs feature enabled' do + # before do + # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) + # end + # + # context 'when a truthy deltas_only is not passed to args' do + # it 'fetches diffs from Gitaly server' do + # expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). + # with(commit) + # + # commit.raw_diffs + # end + # end + # + # context 'when a truthy deltas_only is passed to args' do + # it 'fetches diffs using Rugged' do + # opts = { deltas_only: true } + # + # expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) + # expect(commit.raw).to receive(:diffs).with(opts) + # + # commit.raw_diffs(opts) + # end + # end + # end + # end end diff --git a/spec/models/concerns/ignorable_column_spec.rb b/spec/models/concerns/ignorable_column_spec.rb new file mode 100644 index 00000000000..dba9fe43327 --- /dev/null +++ b/spec/models/concerns/ignorable_column_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe IgnorableColumn do + let :base_class do + Class.new do + def self.columns + # This method does not have access to "double" + [Struct.new(:name).new('id'), Struct.new(:name).new('title')] + end + end + end + + let :model do + Class.new(base_class) do + include IgnorableColumn + end + end + + describe '.columns' do + it 'returns the columns, excluding the ignored ones' do + model.ignore_column(:title) + + expect(model.columns.map(&:name)).to eq(%w(id)) + end + end + + describe '.ignored_columns' do + it 'returns a Set' do + expect(model.ignored_columns).to be_an_instance_of(Set) + end + + it 'returns the names of the ignored columns' do + model.ignore_column(:title) + + expect(model.ignored_columns).to eq(Set.new(%w(title))) + end + end +end diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index 6d6c9f2adfc..eff41d85972 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -34,8 +34,18 @@ describe ContainerRepository do end describe '#path' do - it 'returns a full path to the repository' do - expect(repository.path).to eq('group/test/my_image') + context 'when project path does not contain uppercase letters' do + it 'returns a full path to the repository' do + expect(repository.path).to eq('group/test/my_image') + end + end + + context 'when path contains uppercase letters' do + let(:project) { create(:project, path: 'MY_PROJECT', group: group) } + + it 'returns a full path without capital letters' do + expect(repository.path).to eq('group/my_project/my_image') + end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index af7753caba6..070716e859a 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -110,17 +110,18 @@ describe Environment, models: true do end end - context 'Gitaly find_ref_name feature enabled' do - before do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:find_ref_name).and_return(true) - end - - it 'calls GitalyClient' do - expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:find_ref_name) - - environment.first_deployment_for(commit) - end - end + # TODO: Uncomment when feature is reenabled + # context 'Gitaly find_ref_name feature enabled' do + # before do + # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:find_ref_name).and_return(true) + # end + # + # it 'calls GitalyClient' do + # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:find_ref_name) + # + # environment.first_deployment_for(commit) + # end + # end end describe '#environment_type' do diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index a9139f7d4ab..80ca19acdda 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -42,11 +42,27 @@ describe Label, models: true do end end + describe '#color' do + it 'strips color' do + label = described_class.new(color: ' #abcdef ') + label.valid? + + expect(label.color).to eq('#abcdef') + end + end + describe '#title' do it 'sanitizes title' do label = described_class.new(title: '<b>foo & bar?</b>') expect(label.title).to eq('foo & bar?') end + + it 'strips title' do + label = described_class.new(title: ' label ') + label.valid? + + expect(label.title).to eq('label') + end end describe 'priorization' do diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 3c4bf3f4ddb..557ea97b008 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -622,12 +622,22 @@ describe Note, models: true do describe 'expiring ETag cache' do let(:note) { build(:note_on_issue) } - it "expires cache for note's issue when note is saved" do + def expect_expiration(note) expect_any_instance_of(Gitlab::EtagCaching::Store) .to receive(:touch) .with("/#{note.project.namespace.to_param}/#{note.project.to_param}/noteable/issue/#{note.noteable.id}/notes") + end + + it "expires cache for note's issue when note is saved" do + expect_expiration(note) note.save! end + + it "expires cache for note's issue when note is destroyed" do + expect_expiration(note) + + note.destroy! + end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 8bd436558cb..5e5c2b016b6 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1829,16 +1829,17 @@ describe Repository, models: true do end end - describe '#is_ancestor?' do - context 'Gitaly is_ancestor feature enabled' do - it 'asks Gitaly server if it\'s an ancestor' do - commit = repository.commit - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(true) - expect(Gitlab::GitalyClient::Commit).to receive(:is_ancestor). - with(repository.raw_repository, commit.id, commit.id).and_return(true) - - expect(repository.is_ancestor?(commit.id, commit.id)).to be true - end - end - end + # TODO: Uncomment when feature is reenabled + # describe '#is_ancestor?' do + # context 'Gitaly is_ancestor feature enabled' do + # it 'asks Gitaly server if it\'s an ancestor' do + # commit = repository.commit + # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(true) + # expect(Gitlab::GitalyClient::Commit).to receive(:is_ancestor). + # with(repository.raw_repository, commit.id, commit.id).and_return(true) + # + # expect(repository.is_ancestor?(commit.id, commit.id)).to be true + # end + # end + # end end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 5c34ff04152..2077c14ff7a 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -22,7 +22,8 @@ describe GroupPolicy, models: true do :admin_group, :admin_namespace, :admin_group_member, - :change_visibility_level + :change_visibility_level, + :create_subgroup ] end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index b1f8c249092..b1603233f9e 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -22,8 +22,8 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do context "authorized user" do it "returns project hooks" do get api("/projects/#{project.id}/hooks", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(response).to include_pagination_headers expect(json_response.count).to eq(1) @@ -43,6 +43,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do context "unauthorized user" do it "does not access project hooks" do get api("/projects/#{project.id}/hooks", user3) + expect(response).to have_http_status(403) end end @@ -52,6 +53,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do context "authorized user" do it "returns a project hook" do get api("/projects/#{project.id}/hooks/#{hook.id}", user) + expect(response).to have_http_status(200) expect(json_response['url']).to eq(hook.url) expect(json_response['issues_events']).to eq(hook.issues_events) @@ -67,6 +69,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do it "returns a 404 error if hook id is not available" do get api("/projects/#{project.id}/hooks/1234", user) + expect(response).to have_http_status(404) end end @@ -88,7 +91,8 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do it "adds hook to project" do expect do post api("/projects/#{project.id}/hooks", user), - url: "http://example.com", issues_events: true, wiki_page_events: true + url: "http://example.com", issues_events: true, wiki_page_events: true, + job_events: true end.to change {project.hooks.count}.by(1) expect(response).to have_http_status(201) @@ -98,7 +102,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do expect(json_response['merge_requests_events']).to eq(false) expect(json_response['tag_push_events']).to eq(false) expect(json_response['note_events']).to eq(false) - expect(json_response['job_events']).to eq(false) + expect(json_response['job_events']).to eq(true) expect(json_response['pipeline_events']).to eq(false) expect(json_response['wiki_page_events']).to eq(true) expect(json_response['enable_ssl_verification']).to eq(true) @@ -136,7 +140,8 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do describe "PUT /projects/:id/hooks/:hook_id" do it "updates an existing project hook" do put api("/projects/#{project.id}/hooks/#{hook.id}", user), - url: 'http://example.org', push_events: false + url: 'http://example.org', push_events: false, job_events: true + expect(response).to have_http_status(200) expect(json_response['url']).to eq('http://example.org') expect(json_response['issues_events']).to eq(hook.issues_events) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 62f21049b0b..7a07ea618c0 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -144,6 +144,20 @@ describe Projects::CreateService, '#execute', services: true do end end + context 'when a bad service template is created' do + before do + create(:service, type: 'DroneCiService', project: nil, template: true, active: true) + end + + it 'reports an error in the imported project' do + opts[:import_url] = 'http://www.gitlab.com/gitlab-org/gitlab-ce' + project = create_project(user, opts) + + expect(project.errors.full_messages_for(:base).first).to match /Unable to save project. Error: Unable to save DroneCiService/ + expect(project.services.count).to eq 0 + end + end + def create_project(user, opts) Projects::CreateService.new(user, opts).execute end diff --git a/spec/services/users/build_service_spec.rb b/spec/services/users/build_service_spec.rb new file mode 100644 index 00000000000..2a6bfc1b3a0 --- /dev/null +++ b/spec/services/users/build_service_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Users::BuildService, services: true do + describe '#execute' do + let(:params) do + { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass' } + end + + context 'with an admin user' do + let(:admin_user) { create(:admin) } + let(:service) { described_class.new(admin_user, params) } + + it 'returns a valid user' do + expect(service.execute).to be_valid + end + end + + context 'with non admin user' do + let(:user) { create(:user) } + let(:service) { described_class.new(user, params) } + + it 'raises AccessDeniedError exception' do + expect { service.execute }.to raise_error Gitlab::Access::AccessDeniedError + end + end + + context 'with nil user' do + let(:service) { described_class.new(nil, params) } + + it 'returns a valid user' do + expect(service.execute).to be_valid + end + + context 'when "send_user_confirmation_email" application setting is true' do + before do + stub_application_setting(send_user_confirmation_email: true, signup_enabled?: true) + end + + it 'does not confirm the user' do + expect(service.execute).not_to be_confirmed + end + end + + context 'when "send_user_confirmation_email" application setting is false' do + before do + stub_application_setting(send_user_confirmation_email: false, signup_enabled?: true) + end + + it 'confirms the user' do + expect(service.execute).to be_confirmed + end + end + end + end +end diff --git a/spec/services/users/create_service_spec.rb b/spec/services/users/create_service_spec.rb index a111aec2f89..75746278573 100644 --- a/spec/services/users/create_service_spec.rb +++ b/spec/services/users/create_service_spec.rb @@ -1,38 +1,6 @@ require 'spec_helper' describe Users::CreateService, services: true do - describe '#build' do - let(:params) do - { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass' } - end - - context 'with an admin user' do - let(:admin_user) { create(:admin) } - let(:service) { described_class.new(admin_user, params) } - - it 'returns a valid user' do - expect(service.build).to be_valid - end - end - - context 'with non admin user' do - let(:user) { create(:user) } - let(:service) { described_class.new(user, params) } - - it 'raises AccessDeniedError exception' do - expect { service.build }.to raise_error Gitlab::Access::AccessDeniedError - end - end - - context 'with nil user' do - let(:service) { described_class.new(nil, params) } - - it 'returns a valid user' do - expect(service.build).to be_valid - end - end - end - describe '#execute' do let(:admin_user) { create(:admin) } @@ -185,40 +153,18 @@ describe Users::CreateService, services: true do end let(:service) { described_class.new(nil, params) } - context 'when "send_user_confirmation_email" application setting is true' do - before do - current_application_settings = double(:current_application_settings, send_user_confirmation_email: true, signup_enabled?: true) - allow(service).to receive(:current_application_settings).and_return(current_application_settings) - end - - it 'does not confirm the user' do - expect(service.execute).not_to be_confirmed - end - end - - context 'when "send_user_confirmation_email" application setting is false' do - before do - current_application_settings = double(:current_application_settings, send_user_confirmation_email: false, signup_enabled?: true) - allow(service).to receive(:current_application_settings).and_return(current_application_settings) - end - - it 'confirms the user' do - expect(service.execute).to be_confirmed - end - - it 'persists the given attributes' do - user = service.execute - user.reload - - expect(user).to have_attributes( - name: params[:name], - username: params[:username], - email: params[:email], - password: params[:password], - created_by_id: nil, - admin: false - ) - end + it 'persists the given attributes' do + user = service.execute + user.reload + + expect(user).to have_attributes( + name: params[:name], + username: params[:username], + email: params[:email], + password: params[:password], + created_by_id: nil, + admin: false + ) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4eb5b150af5..e67ad8f3455 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,8 +9,14 @@ require 'rspec/rails' require 'shoulda/matchers' require 'rspec/retry' -if (ENV['RSPEC_PROFILING_POSTGRES_URL'] || ENV['RSPEC_PROFILING']) && - (!ENV.has_key?('CI') || ENV['CI_COMMIT_REF_NAME'] == 'master') +rspec_profiling_is_configured = + ENV['RSPEC_PROFILING_POSTGRES_URL'] || + ENV['RSPEC_PROFILING'] +branch_can_be_profiled = + ENV['CI_COMMIT_REF_NAME'] == 'master' || + ENV['CI_COMMIT_REF_NAME'] =~ /rspec-profile/ + +if rspec_profiling_is_configured && (!ENV.key?('CI') || branch_can_be_profiled) require 'rspec_profiling/rspec' end @@ -59,6 +65,10 @@ RSpec.configure do |config| TestEnv.init end + config.after(:suite) do + TestEnv.cleanup + end + if ENV['CI'] # Retry only on feature specs that use JS config.around :each, :js do |ex| diff --git a/spec/features/discussion_comments_spec.rb b/spec/support/features/discussion_comments_shared_example.rb index ae778118c5c..1a061ef069e 100644 --- a/spec/features/discussion_comments_spec.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -1,5 +1,3 @@ -require 'spec_helper' - shared_examples 'discussion comments' do |resource_name| let(:form_selector) { '.js-main-target-form' } let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" } @@ -9,11 +7,9 @@ shared_examples 'discussion comments' do |resource_name| let(:close_selector) { "#{form_selector} .btn-comment-and-close" } let(:comments_selector) { '.timeline > .note.timeline-entry' } - it 'should show a comment type toggle' do + it 'clicking "Comment" will post a comment' do expect(page).to have_selector toggle_selector - end - it 'clicking "Comment" will post a comment' do find("#{form_selector} .note-textarea").send_keys('a') find(submit_selector).click @@ -49,44 +45,29 @@ shared_examples 'discussion comments' do |resource_name| find(toggle_selector).click end - it 'opens a comment type dropdown with "Comment" and "Start discussion"' do + it 'has a "Comment" item (selected by default) and "Start discussion" item' do expect(page).to have_selector menu_selector - end - - it 'has a "Comment" item' do - menu = find(menu_selector) - - expect(menu).to have_content 'Comment' - expect(menu).to have_content "Add a general comment to this #{resource_name}." - end - it 'has a "Start discussion" item' do - menu = find(menu_selector) - - expect(menu).to have_content 'Start discussion' - expect(menu).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}." - end - - it 'has the "Comment" item selected by default' do find("#{menu_selector} li", match: :first) items = all("#{menu_selector} li") expect(items.first).to have_content 'Comment' + expect(items.first).to have_content "Add a general comment to this #{resource_name}." expect(items.first).to have_selector '.fa-check' expect(items.first['class']).to match 'droplab-item-selected' expect(items.last).to have_content 'Start discussion' + expect(items.last).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}." expect(items.last).not_to have_selector '.fa-check' expect(items.last['class']).not_to match 'droplab-item-selected' end - it 'closes the menu when clicking the toggle' do + it 'closes the menu when clicking the toggle or body' do find(toggle_selector).click expect(page).not_to have_selector menu_selector - end - it 'closes the menu when clicking the body' do + find(toggle_selector).click find('body').click expect(page).not_to have_selector menu_selector @@ -104,12 +85,10 @@ shared_examples 'discussion comments' do |resource_name| all("#{menu_selector} li").last.click end - it 'updates the note_type input to "DiscussionNote"' do - expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote') - end - - it 'updates the submit button text' do + it 'updates the submit button text, note_type input and closes the dropdown' do expect(find(dropdown_selector)).to have_content 'Start discussion' + expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote') + expect(page).not_to have_selector menu_selector end if resource_name =~ /(issue|merge request)/ @@ -124,10 +103,6 @@ shared_examples 'discussion comments' do |resource_name| end end - it 'closes the dropdown' do - expect(page).not_to have_selector menu_selector - end - it 'clicking "Start discussion" will post a discussion' do find(submit_selector).click @@ -176,12 +151,10 @@ shared_examples 'discussion comments' do |resource_name| find("#{menu_selector} li", match: :first).click end - it 'clears the note_type input"' do - expect(find("#{form_selector} #note_type", visible: false).value).to eq('') - end - - it 'updates the submit button text' do + it 'updates the submit button text, clears the note_type input and closes the dropdown' do expect(find(dropdown_selector)).to have_content 'Comment' + expect(find("#{form_selector} #note_type", visible: false).value).to eq('') + expect(page).not_to have_selector menu_selector end if resource_name =~ /(issue|merge request)/ @@ -196,10 +169,6 @@ shared_examples 'discussion comments' do |resource_name| end end - it 'closes the dropdown' do - expect(page).not_to have_selector menu_selector - end - it 'should have "Comment" selected when opening the menu' do find(toggle_selector).click @@ -242,54 +211,3 @@ shared_examples 'discussion comments' do |resource_name| end end end - -describe 'Discussion Comments', :feature, :js do - include RepoHelpers - - let(:user) { create(:user) } - let(:project) { create(:project) } - - before do - project.team << [user, :developer] - - login_as(user) - end - - describe 'on a merge request' do - let(:merge_request) { create(:merge_request, source_project: project) } - - before do - visit namespace_project_merge_request_path(project.namespace, project, merge_request) - end - - it_behaves_like 'discussion comments', 'merge request' - end - - describe 'on an issue' do - let(:issue) { create(:issue, project: project) } - - before do - visit namespace_project_issue_path(project.namespace, project, issue) - end - - it_behaves_like 'discussion comments', 'issue' - end - - describe 'on an snippet' do - let(:snippet) { create(:project_snippet, :private, project: project, author: user) } - - before do - visit namespace_project_snippet_path(project.namespace, project, snippet) - end - - it_behaves_like 'discussion comments', 'snippet' - end - - describe 'on a commit' do - before do - visit namespace_project_commit_path(project.namespace, project, sample_commit.id) - end - - it_behaves_like 'discussion comments', 'commit' - end -end diff --git a/spec/support/fixture_helpers.rb b/spec/support/fixture_helpers.rb index a05c9d18002..5515c355cea 100644 --- a/spec/support/fixture_helpers.rb +++ b/spec/support/fixture_helpers.rb @@ -1,8 +1,11 @@ module FixtureHelpers def fixture_file(filename) return '' if filename.blank? - file_path = File.expand_path(Rails.root.join('spec/fixtures/', filename)) - File.read(file_path) + File.read(expand_fixture_path(filename)) + end + + def expand_fixture_path(filename) + File.expand_path(Rails.root.join('spec/fixtures/', filename)) end end diff --git a/spec/support/gitaly.rb b/spec/support/gitaly.rb new file mode 100644 index 00000000000..7aca902fc61 --- /dev/null +++ b/spec/support/gitaly.rb @@ -0,0 +1,7 @@ +if Gitlab::GitalyClient.enabled? + RSpec.configure do |config| + config.before(:each) do + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true) + end + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1b5cb71a6b0..60c2096a126 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -64,6 +64,8 @@ module TestEnv # Setup GitLab shell for test instance setup_gitlab_shell + setup_gitaly if Gitlab::GitalyClient.enabled? + # Create repository for FactoryGirl.create(:project) setup_factory_repo @@ -71,6 +73,10 @@ module TestEnv setup_forked_repo end + def cleanup + stop_gitaly + end + def disable_mailer allow_any_instance_of(NotificationService).to receive(:mailer). and_return(double.as_null_object) @@ -92,7 +98,7 @@ module TestEnv tmp_test_path = Rails.root.join('tmp', 'tests', '**') Dir[tmp_test_path].each do |entry| - unless File.basename(entry) =~ /\Agitlab-(shell|test|test_bare|test-fork|test-fork_bare)\z/ + unless File.basename(entry) =~ /\A(gitaly|gitlab-(shell|test|test_bare|test-fork|test-fork_bare))\z/ FileUtils.rm_rf(entry) end end @@ -110,6 +116,28 @@ module TestEnv end end + def setup_gitaly + socket_path = Gitlab::GitalyClient.get_address('default').sub(/\Aunix:/, '') + gitaly_dir = File.dirname(socket_path) + + unless File.directory?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") + raise "Can't clone gitaly" + end + + start_gitaly(gitaly_dir, socket_path) + end + + def start_gitaly(gitaly_dir, socket_path) + gitaly_exec = File.join(gitaly_dir, 'gitaly') + @gitaly_pid = spawn({ "GITALY_SOCKET_PATH" => socket_path }, gitaly_exec, [:out, :err] => '/dev/null') + end + + def stop_gitaly + return unless @gitaly_pid + + Process.kill('KILL', @gitaly_pid) + end + def setup_factory_repo setup_repo(factory_repo_path, factory_repo_path_bare, factory_repo_name, BRANCH_SHA) diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index b369dcbb305..aaf998a546f 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -8,7 +8,7 @@ describe 'gitlab:gitaly namespace rake task' do describe 'install' do let(:repo) { 'https://gitlab.com/gitlab-org/gitaly.git' } let(:clone_path) { Rails.root.join('tmp/tests/gitaly').to_s } - let(:tag) { "v#{File.read(Rails.root.join(Gitlab::GitalyClient::SERVER_VERSION_FILE)).chomp}" } + let(:version) { File.read(Rails.root.join(Gitlab::GitalyClient::SERVER_VERSION_FILE)).chomp } context 'no dir given' do it 'aborts and display a help message' do @@ -21,7 +21,7 @@ describe 'gitlab:gitaly namespace rake task' do context 'when an underlying Git command fail' do it 'aborts and display a help message' do expect_any_instance_of(Object). - to receive(:checkout_or_clone_tag).and_raise 'Git error' + to receive(:checkout_or_clone_version).and_raise 'Git error' expect { run_rake_task('gitlab:gitaly:install', clone_path) }.to raise_error 'Git error' end @@ -32,9 +32,9 @@ describe 'gitlab:gitaly namespace rake task' do expect(Dir).to receive(:chdir).with(clone_path) end - it 'calls checkout_or_clone_tag with the right arguments' do + it 'calls checkout_or_clone_version with the right arguments' do expect_any_instance_of(Object). - to receive(:checkout_or_clone_tag).with(tag: tag, repo: repo, target_dir: clone_path) + to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path) run_rake_task('gitlab:gitaly:install', clone_path) end @@ -48,7 +48,7 @@ describe 'gitlab:gitaly namespace rake task' do context 'gmake is available' do before do - expect_any_instance_of(Object).to receive(:checkout_or_clone_tag) + expect_any_instance_of(Object).to receive(:checkout_or_clone_version) allow_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true) end @@ -62,7 +62,7 @@ describe 'gitlab:gitaly namespace rake task' do context 'gmake is not available' do before do - expect_any_instance_of(Object).to receive(:checkout_or_clone_tag) + expect_any_instance_of(Object).to receive(:checkout_or_clone_version) allow_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true) end diff --git a/spec/tasks/gitlab/task_helpers_spec.rb b/spec/tasks/gitlab/task_helpers_spec.rb index 86e42d845ce..3d9ba7cdc6f 100644 --- a/spec/tasks/gitlab/task_helpers_spec.rb +++ b/spec/tasks/gitlab/task_helpers_spec.rb @@ -10,19 +10,38 @@ describe Gitlab::TaskHelpers do let(:repo) { 'https://gitlab.com/gitlab-org/gitlab-test.git' } let(:clone_path) { Rails.root.join('tmp/tests/task_helpers_tests').to_s } + let(:version) { '1.1.0' } let(:tag) { 'v1.1.0' } - describe '#checkout_or_clone_tag' do + describe '#checkout_or_clone_version' do before do allow(subject).to receive(:run_command!) - expect(subject).to receive(:reset_to_tag).with(tag, clone_path) end - context 'target_dir does not exist' do - it 'clones the repo, retrieve the tag from origin, and checkout the tag' do + it 'checkout the version and reset to it' do + expect(subject).to receive(:checkout_version).with(tag, clone_path) + expect(subject).to receive(:reset_to_version).with(tag, clone_path) + + subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path) + end + + context 'with a branch version' do + let(:version) { '=branch_name' } + let(:branch) { 'branch_name' } + + it 'checkout the version and reset to it with a branch name' do + expect(subject).to receive(:checkout_version).with(branch, clone_path) + expect(subject).to receive(:reset_to_version).with(branch, clone_path) + + subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path) + end + end + + context "target_dir doesn't exist" do + it 'clones the repo' do expect(subject).to receive(:clone_repo).with(repo, clone_path) - subject.checkout_or_clone_tag(tag: tag, repo: repo, target_dir: clone_path) + subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path) end end @@ -31,10 +50,10 @@ describe Gitlab::TaskHelpers do expect(Dir).to receive(:exist?).and_return(true) end - it 'fetch and checkout the tag' do - expect(subject).to receive(:checkout_tag).with(tag, clone_path) + it "doesn't clone the repository" do + expect(subject).not_to receive(:clone_repo) - subject.checkout_or_clone_tag(tag: tag, repo: repo, target_dir: clone_path) + subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path) end end end @@ -48,49 +67,23 @@ describe Gitlab::TaskHelpers do end end - describe '#checkout_tag' do + describe '#checkout_version' do it 'clones the repo in the target dir' do expect(subject). - to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} fetch --tags --quiet]) + to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} fetch --quiet]) expect(subject). to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} checkout --quiet #{tag}]) - subject.checkout_tag(tag, clone_path) + subject.checkout_version(tag, clone_path) end end - describe '#reset_to_tag' do - let(:tag) { 'v1.1.0' } - before do + describe '#reset_to_version' do + it 'resets --hard to the given version' do expect(subject). to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} reset --hard #{tag}]) - end - context 'when the tag is not checked out locally' do - before do - expect(subject). - to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} describe -- #{tag}]).and_raise(Gitlab::TaskFailedError) - end - - it 'fetch origin, ensure the tag exists, and resets --hard to the given tag' do - expect(subject). - to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} fetch origin]) - expect(subject). - to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} describe -- origin/#{tag}]).and_return(tag) - - subject.reset_to_tag(tag, clone_path) - end - end - - context 'when the tag is checked out locally' do - before do - expect(subject). - to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} describe -- #{tag}]).and_return(tag) - end - - it 'resets --hard to the given tag' do - subject.reset_to_tag(tag, clone_path) - end + subject.reset_to_version(tag, clone_path) end end end diff --git a/spec/tasks/gitlab/workhorse_rake_spec.rb b/spec/tasks/gitlab/workhorse_rake_spec.rb index 8a66a4aa047..63d1cf2bbe5 100644 --- a/spec/tasks/gitlab/workhorse_rake_spec.rb +++ b/spec/tasks/gitlab/workhorse_rake_spec.rb @@ -8,7 +8,7 @@ describe 'gitlab:workhorse namespace rake task' do describe 'install' do let(:repo) { 'https://gitlab.com/gitlab-org/gitlab-workhorse.git' } let(:clone_path) { Rails.root.join('tmp/tests/gitlab-workhorse').to_s } - let(:tag) { "v#{File.read(Rails.root.join(Gitlab::Workhorse::VERSION_FILE)).chomp}" } + let(:version) { File.read(Rails.root.join(Gitlab::Workhorse::VERSION_FILE)).chomp } context 'no dir given' do it 'aborts and display a help message' do @@ -21,7 +21,7 @@ describe 'gitlab:workhorse namespace rake task' do context 'when an underlying Git command fail' do it 'aborts and display a help message' do expect_any_instance_of(Object). - to receive(:checkout_or_clone_tag).and_raise 'Git error' + to receive(:checkout_or_clone_version).and_raise 'Git error' expect { run_rake_task('gitlab:workhorse:install', clone_path) }.to raise_error 'Git error' end @@ -32,9 +32,9 @@ describe 'gitlab:workhorse namespace rake task' do expect(Dir).to receive(:chdir).with(clone_path) end - it 'calls checkout_or_clone_tag with the right arguments' do + it 'calls checkout_or_clone_version with the right arguments' do expect_any_instance_of(Object). - to receive(:checkout_or_clone_tag).with(tag: tag, repo: repo, target_dir: clone_path) + to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path) run_rake_task('gitlab:workhorse:install', clone_path) end @@ -48,7 +48,7 @@ describe 'gitlab:workhorse namespace rake task' do context 'gmake is available' do before do - expect_any_instance_of(Object).to receive(:checkout_or_clone_tag) + expect_any_instance_of(Object).to receive(:checkout_or_clone_version) allow_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true) end @@ -62,7 +62,7 @@ describe 'gitlab:workhorse namespace rake task' do context 'gmake is not available' do before do - expect_any_instance_of(Object).to receive(:checkout_or_clone_tag) + expect_any_instance_of(Object).to receive(:checkout_or_clone_version) allow_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true) end diff --git a/vendor/assets/javascripts/notebooklab.js b/vendor/assets/javascripts/notebooklab.js index 601a645b655..b8cfdc53b48 100644 --- a/vendor/assets/javascripts/notebooklab.js +++ b/vendor/assets/javascripts/notebooklab.js @@ -699,6 +699,48 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de // // +var renderer = new _marked2.default.Renderer(); + +/* + Regex to match KaTex blocks. + + Supports the following: + + \begin{equation}<math>\end{equation} + $$<math>$$ + inline $<math>$ + + The matched text then goes through the KaTex renderer & then outputs the HTML +*/ +var katexRegexString = '(\n ^\\\\begin{[a-zA-Z]+}\\s\n |\n ^\\$\\$\n |\n \\s\\$(?!\\$)\n)\n (.+?)\n(\n \\s\\\\end{[a-zA-Z]+}$\n |\n \\$\\$$\n |\n \\$\n)\n'.replace(/\s/g, '').trim(); + +renderer.paragraph = function (t) { + var text = t; + var inline = false; + + if (typeof katex !== 'undefined') { + var katexString = text.replace(/\\/g, '\\'); + var matches = new RegExp(katexRegexString, 'gi').exec(katexString); + + if (matches && matches.length > 0) { + if (matches[1].trim() === '$' && matches[3].trim() === '$') { + inline = true; + + text = katexString.replace(matches[0], '') + ' ' + katex.renderToString(matches[2]); + } else { + text = katex.renderToString(matches[2]); + } + } + } + + return '<p class="' + (inline ? 'inline-katex' : '') + '">' + text + '</p>'; +}; + +_marked2.default.setOptions({ + sanitize: true, + renderer: renderer +}); + exports.default = { components: { prompt: _prompt2.default @@ -711,20 +753,7 @@ exports.default = { }, computed: { markdown: function markdown() { - var regex = new RegExp('^\\$\\$(.*)\\$\\$$', 'g'); - - var source = this.cell.source.map(function (line) { - var matches = regex.exec(line.trim()); - - // Only render use the Katex library if it is actually loaded - if (matches && matches.length > 0 && typeof katex !== 'undefined') { - return katex.renderToString(matches[1]); - } - - return line; - }); - - return (0, _marked2.default)(source.join('')); + return (0, _marked2.default)(this.cell.source.join('')); } } }; @@ -3047,7 +3076,7 @@ exports = module.exports = __webpack_require__(1)(undefined); // module -exports.push([module.i, ".markdown .katex{display:block;text-align:center}", ""]); +exports.push([module.i, ".markdown .katex{display:block;text-align:center}.markdown .inline-katex .katex{display:inline;text-align:initial}", ""]); // exports diff --git a/vendor/assets/javascripts/pdf.worker.js b/vendor/assets/javascripts/pdf.worker.js index f8a94e207f8..970caaaba86 100644 --- a/vendor/assets/javascripts/pdf.worker.js +++ b/vendor/assets/javascripts/pdf.worker.js @@ -73,7 +73,7 @@ return /******/ (function(modules) { // webpackBootstrap /******/ __webpack_require__.p = ""; /******/ /******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = 16); +/******/ return __webpack_require__(__webpack_require__.s = 24); /******/ }) /************************************************************************/ /******/ ({ @@ -20214,6 +20214,7 @@ var stringToUTF8String = sharedUtil.stringToUTF8String; var warn = sharedUtil.warn; var createValidAbsoluteUrl = sharedUtil.createValidAbsoluteUrl; var Util = sharedUtil.Util; +var Dict = corePrimitives.Dict; var Ref = corePrimitives.Ref; var RefSet = corePrimitives.RefSet; var RefSetCache = corePrimitives.RefSetCache; @@ -20233,9 +20234,10 @@ var Catalog = function CatalogClosure() { this.pdfManager = pdfManager; this.xref = xref; this.catDict = xref.getCatalogObj(); + assert(isDict(this.catDict), 'catalog object is not a dictionary'); this.fontCache = new RefSetCache(); this.builtInCMapCache = Object.create(null); - assert(isDict(this.catDict), 'catalog object is not a dictionary'); + this.pageKidsCountCache = new RefSetCache(); this.pageFactory = pageFactory; this.pagePromises = []; } @@ -20551,6 +20553,7 @@ var Catalog = function CatalogClosure() { return shadow(this, 'javaScript', javaScript); }, cleanup: function Catalog_cleanup() { + this.pageKidsCountCache.clear(); var promises = []; this.fontCache.forEach(function (promise) { promises.push(promise); @@ -20577,15 +20580,25 @@ var Catalog = function CatalogClosure() { getPageDict: function Catalog_getPageDict(pageIndex) { var capability = createPromiseCapability(); var nodesToVisit = [this.catDict.getRaw('Pages')]; - var currentPageIndex = 0; - var xref = this.xref; + var count, + currentPageIndex = 0; + var xref = this.xref, + pageKidsCountCache = this.pageKidsCountCache; function next() { while (nodesToVisit.length) { var currentNode = nodesToVisit.pop(); if (isRef(currentNode)) { + count = pageKidsCountCache.get(currentNode); + if (count > 0 && currentPageIndex + count < pageIndex) { + currentPageIndex += count; + continue; + } xref.fetchAsync(currentNode).then(function (obj) { if (isDict(obj, 'Page') || isDict(obj) && !obj.has('Kids')) { if (pageIndex === currentPageIndex) { + if (currentNode && !pageKidsCountCache.has(currentNode)) { + pageKidsCountCache.put(currentNode, 1); + } capability.resolve([obj, currentNode]); } else { currentPageIndex++; @@ -20599,7 +20612,11 @@ var Catalog = function CatalogClosure() { return; } assert(isDict(currentNode), 'page dictionary kid reference points to wrong type of object'); - var count = currentNode.get('Count'); + count = currentNode.get('Count'); + var objId = currentNode.objId; + if (objId && !pageKidsCountCache.has(objId)) { + pageKidsCountCache.put(objId, count); + } if (currentPageIndex + count <= pageIndex) { currentPageIndex += count; continue; @@ -21191,7 +21208,7 @@ var XRef = function XRefClosure() { var num = ref.num; if (num in this.cache) { var cacheEntry = this.cache[num]; - if (isDict(cacheEntry) && !cacheEntry.objId) { + if (cacheEntry instanceof Dict && !cacheEntry.objId) { cacheEntry.objId = ref.toString(); } return cacheEntry; @@ -26178,7 +26195,7 @@ var CMapFactory = function CMapFactoryClosure() { return Promise.resolve(new IdentityCMap(true, 2)); } if (BUILT_IN_CMAPS.indexOf(name) === -1) { - return Promise.reject(new Error('Unknown cMap name: ' + name)); + return Promise.reject(new Error('Unknown CMap name: ' + name)); } assert(fetchBuiltInCMap, 'Built-in CMap parameters are not provided.'); return fetchBuiltInCMap(name).then(function (data) { @@ -28458,9 +28475,6 @@ var Font = function FontClosure() { } glyphId = offsetIndex < 0 ? j : offsets[offsetIndex + j - start]; glyphId = glyphId + delta & 0xFFFF; - if (glyphId === 0) { - continue; - } mappings.push({ charCode: j, glyphId: glyphId @@ -37160,8 +37174,8 @@ exports.Type1Parser = Type1Parser; "use strict"; -var pdfjsVersion = '1.7.395'; -var pdfjsBuild = '07f7c97b'; +var pdfjsVersion = '1.8.172'; +var pdfjsBuild = '8ff1fbe7'; var pdfjsCoreWorker = __w_pdfjs_require__(8); { __w_pdfjs_require__(19); @@ -37646,20 +37660,28 @@ if (typeof PDFJS === 'undefined' || !PDFJS.compatibilityChecked) { } })(); (function checkRequestAnimationFrame() { - function fakeRequestAnimationFrame(callback) { - window.setTimeout(callback, 20); + function installFakeAnimationFrameFunctions() { + window.requestAnimationFrame = function (callback) { + return window.setTimeout(callback, 20); + }; + window.cancelAnimationFrame = function (timeoutID) { + window.clearTimeout(timeoutID); + }; } if (!hasDOM) { return; } if (isIOS) { - window.requestAnimationFrame = fakeRequestAnimationFrame; + installFakeAnimationFrameFunctions(); return; } if ('requestAnimationFrame' in window) { return; } - window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || fakeRequestAnimationFrame; + window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame; + if (!('requestAnimationFrame' in window)) { + installFakeAnimationFrameFunctions(); + } })(); (function checkCanvasSizeLimitation() { if (isIOS || isAndroid) { @@ -38588,7 +38610,7 @@ if (typeof PDFJS === 'undefined' || !PDFJS.compatibilityChecked) { /***/ }), -/***/ 16: +/***/ 24: /***/ (function(module, exports, __webpack_require__) { /* Copyright 2016 Mozilla Foundation diff --git a/vendor/assets/javascripts/pdflab.js b/vendor/assets/javascripts/pdflab.js index 94e7c40e75e..5d9c348ce35 100644 --- a/vendor/assets/javascripts/pdflab.js +++ b/vendor/assets/javascripts/pdflab.js @@ -71,17 +71,10 @@ return /******/ (function(modules) { // webpackBootstrap /******/ if(installedChunks[chunkId] === 0) /******/ return Promise.resolve(); /******/ -/******/ // a Promise means "currently loading". +/******/ // an Promise means "currently loading". /******/ if(installedChunks[chunkId]) { /******/ return installedChunks[chunkId][2]; /******/ } -/******/ -/******/ // setup Promise in chunk cache -/******/ var promise = new Promise(function(resolve, reject) { -/******/ installedChunks[chunkId] = [resolve, reject]; -/******/ }); -/******/ installedChunks[chunkId][2] = promise; -/******/ /******/ // start chunk loading /******/ var head = document.getElementsByTagName('head')[0]; /******/ var script = document.createElement('script'); @@ -106,8 +99,13 @@ return /******/ (function(modules) { // webpackBootstrap /******/ installedChunks[chunkId] = undefined; /******/ } /******/ }; -/******/ head.appendChild(script); /******/ +/******/ var promise = new Promise(function(resolve, reject) { +/******/ installedChunks[chunkId] = [resolve, reject]; +/******/ }); +/******/ installedChunks[chunkId][2] = promise; +/******/ +/******/ head.appendChild(script); /******/ return promise; /******/ }; /******/ @@ -150,7 +148,7 @@ return /******/ (function(modules) { // webpackBootstrap /******/ __webpack_require__.oe = function(err) { console.error(err); throw err; }; /******/ /******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = 7); +/******/ return __webpack_require__(__webpack_require__.s = 23); /******/ }) /************************************************************************/ /******/ ([ @@ -1615,7 +1613,10 @@ var DOMCMapReaderFactory = function DOMCMapReaderFactoryClosure() { request.responseType = 'arraybuffer'; } request.onreadystatechange = function () { - if (request.readyState === XMLHttpRequest.DONE && (request.status === 200 || request.status === 0)) { + if (request.readyState !== XMLHttpRequest.DONE) { + return; + } + if (request.status === 200 || request.status === 0) { var data; if (this.isCompressed && request.response) { data = new Uint8Array(request.response); @@ -1629,8 +1630,8 @@ var DOMCMapReaderFactory = function DOMCMapReaderFactoryClosure() { }); return; } - reject(new Error('Unable to load ' + (this.isCompressed ? 'binary ' : '') + 'CMap at: ' + url)); } + reject(new Error('Unable to load ' + (this.isCompressed ? 'binary ' : '') + 'CMap at: ' + url)); }.bind(this); request.send(null); }.bind(this)); @@ -1670,6 +1671,16 @@ var CustomStyle = function CustomStyleClosure() { }; return CustomStyle; }(); +var RenderingCancelledException = function RenderingCancelledException() { + function RenderingCancelledException(msg, type) { + this.message = msg; + this.type = type; + } + RenderingCancelledException.prototype = new Error(); + RenderingCancelledException.prototype.name = 'RenderingCancelledException'; + RenderingCancelledException.constructor = RenderingCancelledException; + return RenderingCancelledException; +}(); var hasCanvasTypedArrays; hasCanvasTypedArrays = function hasCanvasTypedArrays() { var canvas = document.createElement('canvas'); @@ -1762,6 +1773,8 @@ function getDefaultSetting(id) { return globalSettings ? globalSettings.externalLinkRel : DEFAULT_LINK_REL; case 'enableStats': return !!(globalSettings && globalSettings.enableStats); + case 'pdfjsNext': + return !!(globalSettings && globalSettings.pdfjsNext); default: throw new Error('Unknown default setting: ' + id); } @@ -1789,6 +1802,7 @@ exports.isExternalLinkTargetSet = isExternalLinkTargetSet; exports.isValidUrl = isValidUrl; exports.getFilenameFromUrl = getFilenameFromUrl; exports.LinkTarget = LinkTarget; +exports.RenderingCancelledException = RenderingCancelledException; exports.hasCanvasTypedArrays = hasCanvasTypedArrays; exports.getDefaultSetting = getDefaultSetting; exports.DEFAULT_LINK_REL = DEFAULT_LINK_REL; @@ -2450,6 +2464,7 @@ var FontFaceObject = displayFontLoader.FontFaceObject; var FontLoader = displayFontLoader.FontLoader; var CanvasGraphics = displayCanvas.CanvasGraphics; var Metadata = displayMetadata.Metadata; +var RenderingCancelledException = displayDOMUtils.RenderingCancelledException; var getDefaultSetting = displayDOMUtils.getDefaultSetting; var DOMCanvasFactory = displayDOMUtils.DOMCanvasFactory; var DOMCMapReaderFactory = displayDOMUtils.DOMCMapReaderFactory; @@ -3711,7 +3726,11 @@ var InternalRenderTask = function InternalRenderTaskClosure() { cancel: function InternalRenderTask_cancel() { this.running = false; this.cancelled = true; - this.callback('cancelled'); + if (getDefaultSetting('pdfjsNext')) { + this.callback(new RenderingCancelledException('Rendering cancelled, page ' + this.pageNumber, 'canvas')); + } else { + this.callback('cancelled'); + } }, operatorListChanged: function InternalRenderTask_operatorListChanged() { if (!this.graphicsReady) { @@ -3776,8 +3795,8 @@ var _UnsupportedManager = function UnsupportedManagerClosure() { } }; }(); -exports.version = '1.7.395'; -exports.build = '07f7c97b'; +exports.version = '1.8.172'; +exports.build = '8ff1fbe7'; exports.getDocument = getDocument; exports.PDFDataRangeTransport = PDFDataRangeTransport; exports.PDFWorker = PDFWorker; @@ -5716,8 +5735,8 @@ if (!globalScope.PDFJS) { globalScope.PDFJS = {}; } var PDFJS = globalScope.PDFJS; -PDFJS.version = '1.7.395'; -PDFJS.build = '07f7c97b'; +PDFJS.version = '1.8.172'; +PDFJS.build = '8ff1fbe7'; PDFJS.pdfBug = false; if (PDFJS.verbosity !== undefined) { sharedUtil.setVerbosityLevel(PDFJS.verbosity); @@ -5777,6 +5796,7 @@ PDFJS.disableWebGL = PDFJS.disableWebGL === undefined ? true : PDFJS.disableWebG PDFJS.externalLinkTarget = PDFJS.externalLinkTarget === undefined ? LinkTarget.NONE : PDFJS.externalLinkTarget; PDFJS.externalLinkRel = PDFJS.externalLinkRel === undefined ? DEFAULT_LINK_REL : PDFJS.externalLinkRel; PDFJS.isEvalSupported = PDFJS.isEvalSupported === undefined ? true : PDFJS.isEvalSupported; +PDFJS.pdfjsNext = PDFJS.pdfjsNext === undefined ? false : PDFJS.pdfjsNext; var savedOpenExternalLinksInNewWindow = PDFJS.openExternalLinksInNewWindow; delete PDFJS.openExternalLinksInNewWindow; Object.defineProperty(PDFJS, 'openExternalLinksInNewWindow', { @@ -8227,8 +8247,8 @@ exports.TilingPattern = TilingPattern; "use strict"; -var pdfjsVersion = '1.7.395'; -var pdfjsBuild = '07f7c97b'; +var pdfjsVersion = '1.8.172'; +var pdfjsBuild = '8ff1fbe7'; var pdfjsSharedUtil = __w_pdfjs_require__(0); var pdfjsDisplayGlobal = __w_pdfjs_require__(9); var pdfjsDisplayAPI = __w_pdfjs_require__(3); @@ -8259,6 +8279,7 @@ exports.createObjectURL = pdfjsSharedUtil.createObjectURL; exports.removeNullCharacters = pdfjsSharedUtil.removeNullCharacters; exports.shadow = pdfjsSharedUtil.shadow; exports.createBlob = pdfjsSharedUtil.createBlob; +exports.RenderingCancelledException = pdfjsDisplayDOMUtils.RenderingCancelledException; exports.getFilenameFromUrl = pdfjsDisplayDOMUtils.getFilenameFromUrl; exports.addLinkAttributes = pdfjsDisplayDOMUtils.addLinkAttributes; @@ -8740,20 +8761,28 @@ if (typeof PDFJS === 'undefined' || !PDFJS.compatibilityChecked) { } })(); (function checkRequestAnimationFrame() { - function fakeRequestAnimationFrame(callback) { - window.setTimeout(callback, 20); + function installFakeAnimationFrameFunctions() { + window.requestAnimationFrame = function (callback) { + return window.setTimeout(callback, 20); + }; + window.cancelAnimationFrame = function (timeoutID) { + window.clearTimeout(timeoutID); + }; } if (!hasDOM) { return; } if (isIOS) { - window.requestAnimationFrame = fakeRequestAnimationFrame; + installFakeAnimationFrameFunctions(); return; } if ('requestAnimationFrame' in window) { return; } - window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || fakeRequestAnimationFrame; + window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame; + if (!('requestAnimationFrame' in window)) { + installFakeAnimationFrameFunctions(); + } })(); (function checkCanvasSizeLimitation() { if (isIOS || isAndroid) { @@ -9760,7 +9789,7 @@ function toComment(sourceMap) { return '/*# ' + data + ' */'; } -/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(11).Buffer)) +/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(10).Buffer)) /***/ }), /* 4 */ @@ -9839,7 +9868,7 @@ if (typeof DEBUG !== 'undefined' && DEBUG) { ) } } -var listToStyles = __webpack_require__(23) +var listToStyles = __webpack_require__(21) /* type StyleObject = { @@ -10046,34 +10075,18 @@ function applyToTag (styleElement, obj) { /* styles */ -__webpack_require__(21) +__webpack_require__(19) var Component = __webpack_require__(4)( /* script */ - __webpack_require__(8), + __webpack_require__(7), /* template */ - __webpack_require__(19), + __webpack_require__(17), /* scopeId */ null, /* cssModules */ null ) -Component.options.__file = "/Users/samrose/Projects/pdflab/src/index.vue" -if (Component.esModule && Object.keys(Component.esModule).some(function (key) {return key !== "default" && key !== "__esModule"})) {console.error("named exports are not supported in *.vue files.")} -if (Component.options.functional) {console.error("[vue-loader] index.vue: functional components are not supported with templates, they should use render functions.")} - -/* hot reload */ -if (false) {(function () { - var hotAPI = require("vue-hot-reload-api") - hotAPI.install(require("vue"), false) - if (!hotAPI.compatible) return - module.hot.accept() - if (!module.hot.data) { - hotAPI.createRecord("data-v-7c7bed7e", Component.options) - } else { - hotAPI.reload("data-v-7c7bed7e", Component.options) - } -})()} module.exports = Component.exports @@ -10085,25 +10098,6 @@ module.exports = Component.exports "use strict"; -var PDF = __webpack_require__(6); -var pdfjsLib = __webpack_require__(2); - -module.exports = { - install: function install(_vue, _ref) { - var workerSrc = _ref.workerSrc; - - pdfjsLib.PDFJS.workerSrc = workerSrc; - _vue.component('pdf-lab', PDF); - } -}; - -/***/ }), -/* 8 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - Object.defineProperty(exports, "__esModule", { value: true }); @@ -10112,7 +10106,7 @@ var _pdfjsDist = __webpack_require__(2); var _pdfjsDist2 = _interopRequireDefault(_pdfjsDist); -var _index = __webpack_require__(18); +var _index = __webpack_require__(16); var _index2 = _interopRequireDefault(_index); @@ -10138,7 +10132,7 @@ exports.default = { }, data: function data() { return { - isLoading: false, + loading: false, pages: [] }; }, @@ -10163,17 +10157,17 @@ exports.default = { }).catch(function (error) { return _this.$emit('pdflaberror', error); }).then(function () { - return _this.isLoading = false; + _this.loading = false; }); }, renderPages: function renderPages(pdf) { var _this2 = this; var pagePromises = []; - this.isLoading = true; - for (var num = 1; num <= pdf.numPages; num++) { - pagePromises.push(pdf.getPage(num).then(function (page) { - return _this2.pages.push(page); + this.loading = true; + for (var num = 1; num <= pdf.numPages; num += 1) { + pagePromises.push(pdf.getPage(num).then(function (p) { + return _this2.pages.push(p); })); } return Promise.all(pagePromises); @@ -10185,7 +10179,7 @@ exports.default = { }; /***/ }), -/* 9 */ +/* 8 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -10213,10 +10207,16 @@ exports.default = { required: true } }, + data: function data() { + return { + scale: 4, + rendering: false + }; + }, + computed: { viewport: function viewport() { - var scale = 4; - return this.page.getViewport(scale); + return this.page.getViewport(this.scale); }, context: function context() { return this.$refs.canvas.getContext('2d'); @@ -10229,14 +10229,19 @@ exports.default = { } }, mounted: function mounted() { + var _this = this; + this.$refs.canvas.height = this.viewport.height; this.$refs.canvas.width = this.viewport.width; - this.page.render(this.renderContext); + this.rendering = true; + this.page.render(this.renderContext).then(function () { + _this.rendering = false; + }); } }; /***/ }), -/* 10 */ +/* 9 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -10357,7 +10362,7 @@ function fromByteArray (uint8) { /***/ }), -/* 11 */ +/* 10 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -10371,9 +10376,9 @@ function fromByteArray (uint8) { -var base64 = __webpack_require__(10) -var ieee754 = __webpack_require__(14) -var isArray = __webpack_require__(15) +var base64 = __webpack_require__(9) +var ieee754 = __webpack_require__(13) +var isArray = __webpack_require__(14) exports.Buffer = Buffer exports.SlowBuffer = SlowBuffer @@ -12151,10 +12156,10 @@ function isnan (val) { return val !== val // eslint-disable-line no-self-compare } -/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(24))) +/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(22))) /***/ }), -/* 12 */ +/* 11 */ /***/ (function(module, exports, __webpack_require__) { exports = module.exports = __webpack_require__(3)(undefined); @@ -12162,13 +12167,13 @@ exports = module.exports = __webpack_require__(3)(undefined); // module -exports.push([module.i, "\n.pdf-viewer {\n background: url(" + __webpack_require__(17) + ");\n display: flex;\n flex-flow: column nowrap;\n}\n", ""]); +exports.push([module.i, ".pdf-viewer{background:url(" + __webpack_require__(15) + ");display:flex;flex-flow:column nowrap}", ""]); // exports /***/ }), -/* 13 */ +/* 12 */ /***/ (function(module, exports, __webpack_require__) { exports = module.exports = __webpack_require__(3)(undefined); @@ -12176,13 +12181,13 @@ exports = module.exports = __webpack_require__(3)(undefined); // module -exports.push([module.i, "\n.pdf-page {\n margin: 8px auto 0 auto;\n border-top: 1px #ddd solid;\n border-bottom: 1px #ddd solid;\n width: 100%;\n}\n.pdf-page:first-child {\n margin-top: 0px;\n border-top: 0px;\n}\n.pdf-page:last-child {\n margin-bottom: 0px;\n border-bottom: 0px;\n}\n", ""]); +exports.push([module.i, ".pdf-page{margin:8px auto 0;border-top:1px solid #ddd;border-bottom:1px solid #ddd;width:100%}.pdf-page:first-child{margin-top:0;border-top:0}.pdf-page:last-child{margin-bottom:0;border-bottom:0}", ""]); // exports /***/ }), -/* 14 */ +/* 13 */ /***/ (function(module, exports) { exports.read = function (buffer, offset, isLE, mLen, nBytes) { @@ -12272,7 +12277,7 @@ exports.write = function (buffer, value, offset, isLE, mLen, nBytes) { /***/ }), -/* 15 */ +/* 14 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -12283,53 +12288,36 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 16 */, -/* 17 */ +/* 15 */ /***/ (function(module, exports) { module.exports = "data:image/gif;base64,R0lGODlhCgAKAIAAAOXl5f///yH5BAAAAAAALAAAAAAKAAoAAAIRhB2ZhxoM3GMSykqd1VltzxQAOw==" /***/ }), -/* 18 */ +/* 16 */ /***/ (function(module, exports, __webpack_require__) { /* styles */ -__webpack_require__(22) +__webpack_require__(20) var Component = __webpack_require__(4)( /* script */ - __webpack_require__(9), + __webpack_require__(8), /* template */ - __webpack_require__(20), + __webpack_require__(18), /* scopeId */ null, /* cssModules */ null ) -Component.options.__file = "/Users/samrose/Projects/pdflab/src/page/index.vue" -if (Component.esModule && Object.keys(Component.esModule).some(function (key) {return key !== "default" && key !== "__esModule"})) {console.error("named exports are not supported in *.vue files.")} -if (Component.options.functional) {console.error("[vue-loader] index.vue: functional components are not supported with templates, they should use render functions.")} - -/* hot reload */ -if (false) {(function () { - var hotAPI = require("vue-hot-reload-api") - hotAPI.install(require("vue"), false) - if (!hotAPI.compatible) return - module.hot.accept() - if (!module.hot.data) { - hotAPI.createRecord("data-v-7e912b1a", Component.options) - } else { - hotAPI.reload("data-v-7e912b1a", Component.options) - } -})()} module.exports = Component.exports /***/ }), -/* 19 */ -/***/ (function(module, exports, __webpack_require__) { +/* 17 */ +/***/ (function(module, exports) { module.exports={render:function (){var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h; return (_vm.hasPDF) ? _c('div', { @@ -12338,24 +12326,17 @@ module.exports={render:function (){var _vm=this;var _h=_vm.$createElement;var _c return _c('page', { key: index, attrs: { - "v-if": !_vm.isLoading, + "v-if": !_vm.loading, "page": page, "number": index + 1 } }) })) : _vm._e() },staticRenderFns: []} -module.exports.render._withStripped = true -if (false) { - module.hot.accept() - if (module.hot.data) { - require("vue-hot-reload-api").rerender("data-v-7c7bed7e", module.exports) - } -} /***/ }), -/* 20 */ -/***/ (function(module, exports, __webpack_require__) { +/* 18 */ +/***/ (function(module, exports) { module.exports={render:function (){var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h; return _c('canvas', { @@ -12366,32 +12347,25 @@ module.exports={render:function (){var _vm=this;var _h=_vm.$createElement;var _c } }) },staticRenderFns: []} -module.exports.render._withStripped = true -if (false) { - module.hot.accept() - if (module.hot.data) { - require("vue-hot-reload-api").rerender("data-v-7e912b1a", module.exports) - } -} /***/ }), -/* 21 */ +/* 19 */ /***/ (function(module, exports, __webpack_require__) { // style-loader: Adds some css to the DOM by adding a <style> tag // load the styles -var content = __webpack_require__(12); +var content = __webpack_require__(11); if(typeof content === 'string') content = [[module.i, content, '']]; if(content.locals) module.exports = content.locals; // add the styles to the DOM -var update = __webpack_require__(5)("8018213c", content, false); +var update = __webpack_require__(5)("59cf066f", content, true); // Hot Module Replacement if(false) { // When the styles change, update the <style> tags if(!content.locals) { - module.hot.accept("!!../node_modules/css-loader/index.js!../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7c7bed7e\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue", function() { - var newContent = require("!!../node_modules/css-loader/index.js!../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7c7bed7e\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue"); + module.hot.accept("!!../node_modules/css-loader/index.js?minimize!../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7c7bed7e\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue", function() { + var newContent = require("!!../node_modules/css-loader/index.js?minimize!../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7c7bed7e\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue"); if(typeof newContent === 'string') newContent = [[module.id, newContent, '']]; update(newContent); }); @@ -12401,23 +12375,23 @@ if(false) { } /***/ }), -/* 22 */ +/* 20 */ /***/ (function(module, exports, __webpack_require__) { // style-loader: Adds some css to the DOM by adding a <style> tag // load the styles -var content = __webpack_require__(13); +var content = __webpack_require__(12); if(typeof content === 'string') content = [[module.i, content, '']]; if(content.locals) module.exports = content.locals; // add the styles to the DOM -var update = __webpack_require__(5)("6d9dea59", content, false); +var update = __webpack_require__(5)("09f1e2d8", content, true); // Hot Module Replacement if(false) { // When the styles change, update the <style> tags if(!content.locals) { - module.hot.accept("!!../../node_modules/css-loader/index.js!../../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7e912b1a\",\"scoped\":false,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue", function() { - var newContent = require("!!../../node_modules/css-loader/index.js!../../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7e912b1a\",\"scoped\":false,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue"); + module.hot.accept("!!../../node_modules/css-loader/index.js?minimize!../../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7e912b1a\",\"scoped\":false,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue", function() { + var newContent = require("!!../../node_modules/css-loader/index.js?minimize!../../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7e912b1a\",\"scoped\":false,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue"); if(typeof newContent === 'string') newContent = [[module.id, newContent, '']]; update(newContent); }); @@ -12427,7 +12401,7 @@ if(false) { } /***/ }), -/* 23 */ +/* 21 */ /***/ (function(module, exports) { /** @@ -12460,7 +12434,7 @@ module.exports = function listToStyles (parentId, list) { /***/ }), -/* 24 */ +/* 22 */ /***/ (function(module, exports) { var g; @@ -12486,6 +12460,25 @@ try { module.exports = g; +/***/ }), +/* 23 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var PDF = __webpack_require__(6); +var pdfjsLib = __webpack_require__(2); + +module.exports = { + install: function install(_vue) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + pdfjsLib.PDFJS.workerSrc = options.workerSrc || ''; + _vue.component('pdf-lab', PDF); + } +}; + /***/ }) /******/ ]); });
\ No newline at end of file diff --git a/vendor/gitlab-ci-yml/CONTRIBUTING.md b/vendor/gitlab-ci-yml/CONTRIBUTING.md new file mode 100644 index 00000000000..6e5160a2487 --- /dev/null +++ b/vendor/gitlab-ci-yml/CONTRIBUTING.md @@ -0,0 +1,5 @@ +The canonical repository for `.gitlab-ci.yml` templates is +https://gitlab.com/gitlab-org/gitlab-ci-yml. + +GitLab only mirrors the templates. Please submit your merge requests to +https://gitlab.com/gitlab-org/gitlab-ci-yml. |