diff options
564 files changed, 13122 insertions, 2624 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad8022e972f..0bf8cba76f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -197,7 +197,7 @@ The current team labels are: - ~Plan - ~Quality - ~Release -- ~"Security Products" +- ~Secure - ~UX The descriptions on the [labels page][labels-page] explain what falls under the @@ -377,13 +377,14 @@ on those issues. Please select someone with relevant experience from the the commit history for the affected files to find someone. We also use [GitLab Triage] to automate some triaging policies. This is -currently setup as a [scheduled pipeline] running on the [`gl-triage`] branch. +currently setup as a [scheduled pipeline] running on [quality/triage-ops] +project. [described in our handbook]: https://about.gitlab.com/handbook/engineering/issue-triage/ [issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 [GitLab Triage]: https://gitlab.com/gitlab-org/gitlab-triage -[scheduled pipeline]: https://gitlab.com/gitlab-org/gitlab-ce/pipeline_schedules/3732/edit -[`gl-triage`]: https://gitlab.com/gitlab-org/gitlab-ce/tree/gl-triage +[scheduled pipeline]: https://gitlab.com/gitlab-org/quality/triage-ops/pipeline_schedules/10512/edit +[quality/triage-ops]: https://gitlab.com/gitlab-org/quality/triage-ops ### Feature proposals diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index bdc80994dd9..a38b3bd31b1 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.115.0 +0.117.0 @@ -423,7 +423,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.109.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.112.0', require: 'gitaly' gem 'grpc', '~> 1.11.0' # Locked until https://github.com/google/protobuf/issues/4210 is closed diff --git a/Gemfile.lock b/Gemfile.lock index d8f878875f3..1537cacaadd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -284,7 +284,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (0.109.0) + gitaly-proto (0.112.0) google-protobuf (~> 3.1) grpc (~> 1.10) github-linguist (5.3.3) @@ -1048,7 +1048,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 0.109.0) + gitaly-proto (~> 0.112.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-gollum-lib (~> 4.2) diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock index a763dcebe2d..39305927c0f 100644 --- a/Gemfile.rails5.lock +++ b/Gemfile.rails5.lock @@ -287,7 +287,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (0.109.0) + gitaly-proto (0.112.0) google-protobuf (~> 3.1) grpc (~> 1.10) github-linguist (5.3.3) @@ -1058,7 +1058,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 0.109.0) + gitaly-proto (~> 0.112.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-gollum-lib (~> 4.2) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 422becb7db8..25fe2ae553e 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -244,6 +244,18 @@ const Api = { }); }, + branches(id, query = '', options = {}) { + const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: { + search: query, + per_page: 20, + ...options, + }, + }); + }, + createBranch(id, { ref, branch }) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index fa00a3cf386..e8c59fab609 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -53,4 +53,8 @@ export default class Autosave { return window.localStorage.removeItem(this.key); } + + dispose() { + this.field.off('input'); + } } diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 70f20c5c7cf..e34db893989 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -33,19 +33,24 @@ const categoryLabelMap = { const IS_VISIBLE = 'is-visible'; const IS_RENDERED = 'is-rendered'; -class AwardsHandler { +export class AwardsHandler { constructor(emoji) { this.emoji = emoji; this.eventListeners = []; + this.toggleButtonSelector = '.js-add-award'; + this.menuClass = 'js-award-emoji-menu'; + } + + bindEvents() { // If the user shows intent let's pre-build the menu this.registerEventListener( 'one', $(document), 'mouseenter focus', - '.js-add-award', + this.toggleButtonSelector, 'mouseenter focus', () => { - const $menu = $('.emoji-menu'); + const $menu = $(`.${this.menuClass}`); if ($menu.length === 0) { requestAnimationFrame(() => { this.createEmojiMenu(); @@ -53,7 +58,7 @@ class AwardsHandler { } }, ); - this.registerEventListener('on', $(document), 'click', '.js-add-award', e => { + this.registerEventListener('on', $(document), 'click', this.toggleButtonSelector, e => { e.stopPropagation(); e.preventDefault(); this.showEmojiMenu($(e.currentTarget)); @@ -61,15 +66,17 @@ class AwardsHandler { this.registerEventListener('on', $('html'), 'click', e => { const $target = $(e.target); - if (!$target.closest('.emoji-menu').length) { + if (!$target.closest(`.${this.menuClass}`).length) { $('.js-awards-block.current').removeClass('current'); - if ($('.emoji-menu').is(':visible')) { - $('.js-add-award.is-active').removeClass('is-active'); - this.hideMenuElement($('.emoji-menu')); + if ($(`.${this.menuClass}`).is(':visible')) { + $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active'); + this.hideMenuElement($(`.${this.menuClass}`)); } } }); - this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', e => { + + const emojiButtonSelector = `.js-awards-block .js-emoji-btn, .${this.menuClass} .js-emoji-btn`; + this.registerEventListener('on', $(document), 'click', emojiButtonSelector, e => { e.preventDefault(); const $target = $(e.currentTarget); const $glEmojiElement = $target.find('gl-emoji'); @@ -101,7 +108,7 @@ class AwardsHandler { $addBtn.closest('.js-awards-block').addClass('current'); } - const $menu = $('.emoji-menu'); + const $menu = $(`.${this.menuClass}`); const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent(); const $userAuthored = this.isUserAuthored($addBtn); if ($menu.length) { @@ -118,7 +125,7 @@ class AwardsHandler { } else { $addBtn.addClass('is-loading is-active'); this.createEmojiMenu(() => { - const $createdMenu = $('.emoji-menu'); + const $createdMenu = $(`.${this.menuClass}`); $addBtn.removeClass('is-loading'); this.positionMenu($createdMenu, $addBtn); return setTimeout(() => { @@ -156,7 +163,7 @@ class AwardsHandler { } const emojiMenuMarkup = ` - <div class="emoji-menu"> + <div class="emoji-menu ${this.menuClass}"> <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" /> <div class="emoji-menu-content"> @@ -185,7 +192,7 @@ class AwardsHandler { // Avoid the jank and render the remaining categories separately // This will take more time, but makes UI more responsive - const menu = document.querySelector('.emoji-menu'); + const menu = document.querySelector(`.${this.menuClass}`); const emojiContentElement = menu.querySelector('.emoji-menu-content'); const remainingCategories = Object.keys(categoryMap).slice(1); const allCategoriesAddedPromise = remainingCategories.reduce( @@ -270,9 +277,9 @@ class AwardsHandler { if (isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); - this.hideMenuElement($('.emoji-menu')); + this.hideMenuElement($(`.${this.menuClass}`)); - $('.js-add-award.is-active').removeClass('is-active'); + $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active'); const toggleAwardEvent = new CustomEvent('toggleAward', { detail: { awardName: emoji, @@ -291,9 +298,9 @@ class AwardsHandler { return typeof callback === 'function' ? callback() : undefined; }); - this.hideMenuElement($('.emoji-menu')); + this.hideMenuElement($(`.${this.menuClass}`)); - return $('.js-add-award.is-active').removeClass('is-active'); + return $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active'); } addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { @@ -321,7 +328,7 @@ class AwardsHandler { getVotesBlock() { if (isInVueNoteablePage()) { - const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); + const $el = $(`${this.toggleButtonSelector}.is-active`).closest('.note.timeline-entry'); if ($el.length) { return $el; @@ -458,7 +465,7 @@ class AwardsHandler { } createEmoji(votesBlock, emoji) { - if ($('.emoji-menu').length) { + if ($(`.${this.menuClass}`).length) { this.createAwardButtonForVotesBlock(votesBlock, emoji); } this.createEmojiMenu(() => { @@ -538,7 +545,7 @@ class AwardsHandler { this.searchEmojis(term); }); - const $menu = $('.emoji-menu'); + const $menu = $(`.${this.menuClass}`); this.registerEventListener('on', $menu, transitionEndEventString, e => { if (e.target === e.currentTarget) { // Clear the search @@ -608,7 +615,7 @@ class AwardsHandler { this.eventListeners.forEach(entry => { entry.element.off.call(entry.element, ...entry.args); }); - $('.emoji-menu').remove(); + $(`.${this.menuClass}`).remove(); } } @@ -616,7 +623,11 @@ let awardsHandlerPromise = null; export default function loadAwardsHandler(reload = false) { if (!awardsHandlerPromise || reload) { awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then( - Emoji => new AwardsHandler(Emoji), + Emoji => { + const awardsHandler = new AwardsHandler(Emoji); + awardsHandler.bindEvents(); + return awardsHandler; + }, ); } return awardsHandlerPromise; diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 5c7565234d8..3e610a4088c 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -112,12 +112,20 @@ export default { if (e.target) { const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list'); const toBoardType = containerEl.dataset.boardType; + const cloneActions = { + label: ['milestone', 'assignee'], + assignee: ['milestone', 'label'], + milestone: ['label', 'assignee'], + }; if (toBoardType) { const fromBoardType = this.list.type; + // For each list we check if the destination list is + // a the list were we should clone the issue + const shouldClone = Object.entries(cloneActions).some(entry => ( + fromBoardType === entry[0] && entry[1].includes(toBoardType))); - if ((fromBoardType === 'assignee' && toBoardType === 'label') || - (fromBoardType === 'label' && toBoardType === 'assignee')) { + if (shouldClone) { return 'clone'; } } @@ -145,7 +153,8 @@ export default { }); }, onUpdate: (e) => { - const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); + const sortedArray = this.sortable.toArray() + .filter(id => id !== '-1'); gl.issueBoards.BoardsStore .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray); }, diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 371be109229..a9102743bf9 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -51,6 +51,16 @@ gl.issueBoards.BoardSidebar = Vue.extend({ canRemove() { return !this.list.preset; }, + hasLabels() { + return this.issue.labels && this.issue.labels.length; + }, + labelDropdownTitle() { + return this.hasLabels ? + `${this.issue.labels[0].title} ${this.issue.labels.length - 1}+ more` : 'Label'; + }, + selectedLabels() { + return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : ''; + } }, watch: { detail: { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 76467564608..957114cf420 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -108,6 +108,16 @@ gl.issueBoards.BoardsStore = { issue.findAssignee(listTo.assignee)) { const targetIssue = listTo.findIssue(issue.id); targetIssue.removeAssignee(listFrom.assignee); + } else if (listTo.type === 'milestone') { + const currentMilestone = issue.milestone; + const currentLists = this.state.lists + .filter(list => (list.type === 'milestone' && list.id !== listTo.id)) + .filter(list => list.issues.some(listIssue => issue.id === listIssue.id)); + + issue.removeMilestone(currentMilestone); + issue.addMilestone(listTo.milestone); + currentLists.forEach(currentList => currentList.removeIssue(issue)); + listTo.addIssue(issue, listFrom, newIndex); } else { // Add to new lists issues if it doesn't already exist listTo.addIssue(issue, listFrom, newIndex); @@ -125,6 +135,9 @@ gl.issueBoards.BoardsStore = { } else if (listTo.type === 'backlog' && listFrom.type === 'assignee') { issue.removeAssignee(listFrom.assignee); listFrom.removeIssue(issue); + } else if (listTo.type === 'backlog' && listFrom.type === 'milestone') { + issue.removeMilestone(listFrom.milestone); + listFrom.removeIssue(issue); } else if (this.shouldRemoveIssue(listFrom, listTo)) { listFrom.removeIssue(issue); } @@ -144,7 +157,7 @@ gl.issueBoards.BoardsStore = { }, findList (key, val, type = 'label') { const filteredList = this.state.lists.filter((list) => { - const byType = type ? (list.type === type) || (list.type === 'assignee') : true; + const byType = type ? (list.type === type) || (list.type === 'assignee') || (list.type === 'milestone') : true; return list[key] === val && byType; }); diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index e565af800d0..0fdf0c7a389 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -6,7 +6,7 @@ import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; import { - APPLICATION_INSTALLED, + APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE, @@ -177,8 +177,8 @@ export default class Clusters { checkForNewInstalls(prevApplicationMap, newApplicationMap) { const appTitles = Object.keys(newApplicationMap) - .filter(appId => newApplicationMap[appId].status === APPLICATION_INSTALLED && - prevApplicationMap[appId].status !== APPLICATION_INSTALLED && + .filter(appId => newApplicationMap[appId].status === APPLICATION_STATUS.INSTALLED && + prevApplicationMap[appId].status !== APPLICATION_STATUS.INSTALLED && prevApplicationMap[appId].status !== null) .map(appId => newApplicationMap[appId].title); diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index ec52fdfdf32..651f3b50236 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -4,12 +4,7 @@ import eventHub from '../event_hub'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import { - APPLICATION_NOT_INSTALLABLE, - APPLICATION_SCHEDULED, - APPLICATION_INSTALLABLE, - APPLICATION_INSTALLING, - APPLICATION_INSTALLED, - APPLICATION_ERROR, + APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE, @@ -59,49 +54,57 @@ }, }, computed: { + isUnknownStatus() { + return !this.isKnownStatus && this.status !== null; + }, + isKnownStatus() { + return Object.values(APPLICATION_STATUS).includes(this.status); + }, rowJsClass() { return `js-cluster-application-row-${this.id}`; }, installButtonLoading() { return !this.status || - this.status === APPLICATION_SCHEDULED || - this.status === APPLICATION_INSTALLING || + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING || this.requestStatus === REQUEST_LOADING; }, installButtonDisabled() { - // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but + // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but // we already made a request to install and are just waiting for the real-time // to sync up. - return (this.status !== APPLICATION_INSTALLABLE - && this.status !== APPLICATION_ERROR) || + return ((this.status !== APPLICATION_STATUS.INSTALLABLE + && this.status !== APPLICATION_STATUS.ERROR) || this.requestStatus === REQUEST_LOADING || - this.requestStatus === REQUEST_SUCCESS; + this.requestStatus === REQUEST_SUCCESS) && this.isKnownStatus; }, installButtonLabel() { let label; if ( - this.status === APPLICATION_NOT_INSTALLABLE || - this.status === APPLICATION_INSTALLABLE || - this.status === APPLICATION_ERROR + this.status === APPLICATION_STATUS.NOT_INSTALLABLE || + this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.ERROR || + this.isUnknownStatus ) { label = s__('ClusterIntegration|Install'); - } else if (this.status === APPLICATION_SCHEDULED || - this.status === APPLICATION_INSTALLING) { + } else if (this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING) { label = s__('ClusterIntegration|Installing'); - } else if (this.status === APPLICATION_INSTALLED) { + } else if (this.status === APPLICATION_STATUS.INSTALLED || + this.status === APPLICATION_STATUS.UPDATED) { label = s__('ClusterIntegration|Installed'); } return label; }, showManageButton() { - return this.manageLink && this.status === APPLICATION_INSTALLED; + return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED; }, manageButtonLabel() { return s__('ClusterIntegration|Manage'); }, hasError() { - return this.status === APPLICATION_ERROR || + return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE; }, generalErrorDescription() { @@ -182,7 +185,7 @@ </div> </div> <div - v-if="hasError" + v-if="hasError || isUnknownStatus" class="gl-responsive-table-row-layout" role="row" > diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 8ee7279e544..d708a9e595a 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -3,7 +3,7 @@ import _ from 'underscore'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import { APPLICATION_INSTALLED, INGRESS } from '../constants'; +import { APPLICATION_STATUS, INGRESS } from '../constants'; export default { components: { @@ -58,7 +58,7 @@ export default { return INGRESS; }, ingressInstalled() { - return this.applications.ingress.status === APPLICATION_INSTALLED; + return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED; }, ingressExternalIp() { return this.applications.ingress.externalIp; @@ -122,7 +122,7 @@ export default { ); }, jupyterInstalled() { - return this.applications.jupyter.status === APPLICATION_INSTALLED; + return this.applications.jupyter.status === APPLICATION_STATUS.INSTALLED; }, jupyterHostname() { return this.applications.jupyter.hostname; diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 371f71fde44..72fc9355d82 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -1,10 +1,13 @@ // These need to match what is returned from the server -export const APPLICATION_NOT_INSTALLABLE = 'not_installable'; -export const APPLICATION_INSTALLABLE = 'installable'; -export const APPLICATION_SCHEDULED = 'scheduled'; -export const APPLICATION_INSTALLING = 'installing'; -export const APPLICATION_INSTALLED = 'installed'; -export const APPLICATION_ERROR = 'errored'; +export const APPLICATION_STATUS = { + NOT_INSTALLABLE: 'not_installable', + INSTALLABLE: 'installable', + SCHEDULED: 'scheduled', + INSTALLING: 'installing', + INSTALLED: 'installed', + UPDATED: 'updated', + ERROR: 'errored', +}; // These are only used client-side export const REQUEST_LOADING = 'request-loading'; diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index f595f3c3187..589eeee9695 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -19,3 +19,4 @@ import './polyfills/custom_event'; import './polyfills/element'; import './polyfills/event'; import './polyfills/nodelist'; +import './polyfills/request_idle_callback'; diff --git a/app/assets/javascripts/commons/polyfills/request_idle_callback.js b/app/assets/javascripts/commons/polyfills/request_idle_callback.js new file mode 100644 index 00000000000..2356569d06e --- /dev/null +++ b/app/assets/javascripts/commons/polyfills/request_idle_callback.js @@ -0,0 +1,17 @@ +window.requestIdleCallback = + window.requestIdleCallback || + function requestShim(cb) { + const start = Date.now(); + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1); + }; + +window.cancelIdleCallback = + window.cancelIdleCallback || + function cancelShim(id) { + clearTimeout(id); + }; diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index 42e9e568170..8ef9aa7f529 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -12,6 +12,7 @@ export default class CreateItemDropdown { this.fieldName = options.fieldName; this.onSelect = options.onSelect || (() => {}); this.getDataOption = options.getData; + this.getDataRemote = !!options.filterRemote; this.createNewItemFromValueOption = options.createNewItemFromValue; this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); @@ -29,7 +30,7 @@ export default class CreateItemDropdown { this.$dropdown.glDropdown({ data: this.getData.bind(this), filterable: true, - remote: false, + filterRemote: this.getDataRemote, search: { fields: ['text'], }, diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 20483161033..e64d5511d78 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -30,6 +30,7 @@ export default { :render-header="false" :render-diff-file="false" :always-expanded="true" + :discussions-by-diff-order="true" /> </ul> </div> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index ad838a32518..8ad1ea34245 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -71,13 +71,23 @@ export default { required: false, default: false, }, + isHover: { + type: Boolean, + required: false, + default: false, + }, + discussions: { + type: Array, + required: false, + default: () => [], + }, }, computed: { ...mapState({ diffViewType: state => state.diffs.diffViewType, diffFiles: state => state.diffs.diffFiles, }), - ...mapGetters(['isLoggedIn', 'discussionsByLineCode']), + ...mapGetters(['isLoggedIn']), lineHref() { return this.lineCode ? `#${this.lineCode}` : '#'; }, @@ -85,26 +95,22 @@ export default { return ( this.isLoggedIn && this.showCommentButton && + this.isHover && !this.isMatchLine && !this.isContextLine && - !this.hasDiscussions && - !this.isMetaLine + !this.isMetaLine && + !this.hasDiscussions ); }, - discussions() { - return this.discussionsByLineCode[this.lineCode] || []; - }, hasDiscussions() { return this.discussions.length > 0; }, shouldShowAvatarsOnGutter() { - let render = this.hasDiscussions && this.showCommentButton; - if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) { - render = false; + return false; } - return render; + return this.showCommentButton && this.hasDiscussions; }, }, methods: { @@ -176,7 +182,7 @@ export default { v-else > <button - v-show="shouldShowCommentButton" + v-if="shouldShowCommentButton" type="button" class="add-diff-note js-add-diff-note-button" title="Add a comment to this line" @@ -189,7 +195,6 @@ export default { </button> <a v-if="lineNumber" - v-once :data-linenumber="lineNumber" :href="lineHref" > diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 32f9516d332..cbe4551d06b 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,17 +1,17 @@ <script> -import $ from 'jquery'; import { mapState, mapGetters, mapActions } from 'vuex'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import noteForm from '../../notes/components/note_form.vue'; import { getNoteFormData } from '../store/utils'; -import Autosave from '../../autosave'; -import { DIFF_NOTE_TYPE, NOTE_TYPE } from '../constants'; +import autosave from '../../notes/mixins/autosave'; +import { DIFF_NOTE_TYPE } from '../constants'; export default { components: { noteForm, }, + mixins: [autosave], props: { diffFileHash: { type: String, @@ -41,28 +41,35 @@ export default { }, mounted() { if (this.isLoggedIn) { - const noteableData = this.getNoteableData; const keys = [ - NOTE_TYPE, - this.noteableType, - noteableData.id, - noteableData.diff_head_sha, + this.noteableData.diff_head_sha, DIFF_NOTE_TYPE, - noteableData.source_project_id, + this.noteableData.source_project_id, this.line.lineCode, ]; - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys); + this.initAutoSave(this.noteableData, keys); } }, methods: { ...mapActions('diffs', ['cancelCommentForm']), ...mapActions(['saveNote', 'refetchDiscussionById']), - handleCancelCommentForm() { - this.autosave.reset(); + handleCancelCommentForm(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); + + // eslint-disable-next-line no-alert + if (!window.confirm(msg)) { + return; + } + } + this.cancelCommentForm({ lineCode: this.line.lineCode, }); + this.$nextTick(() => { + this.resetAutoSave(); + }); }, handleSaveNote(note) { const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash); diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 5962f30d9bb..33bc8d9971e 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -67,6 +67,11 @@ export default { required: false, default: false, }, + discussions: { + type: Array, + required: false, + default: () => [], + }, }, computed: { ...mapGetters(['isLoggedIn']), @@ -132,10 +137,12 @@ export default { :line-number="lineNumber" :meta-data="normalizedLine.metaData" :show-comment-button="showCommentButton" + :is-hover="isHover" :is-bottom="isBottom" :is-match-line="isMatchLine" :is-context-line="isContentLine" :is-meta-line="isMetaLine" + :discussions="discussions" /> </td> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index ca265dd892c..caf84dc9573 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapGetters } from 'vuex'; +import { mapState } from 'vuex'; import diffDiscussions from './diff_discussions.vue'; import diffLineNoteForm from './diff_line_note_form.vue'; @@ -21,15 +21,16 @@ export default { type: Number, required: true, }, + discussions: { + type: Array, + required: false, + default: () => [], + }, }, computed: { ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), - ...mapGetters(['discussionsByLineCode']), - discussions() { - return this.discussionsByLineCode[this.line.lineCode] || []; - }, className() { return this.discussions.length ? '' : 'js-temp-notes-holder'; }, diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 0197a510ef1..32d65ff994f 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -33,6 +33,11 @@ export default { required: false, default: false, }, + discussions: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -89,6 +94,7 @@ export default { :is-bottom="isBottom" :is-hover="isHover" :show-comment-button="true" + :discussions="discussions" class="diff-line-num old_line" /> <diff-table-cell @@ -98,10 +104,10 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isHover" + :discussions="discussions" class="diff-line-num new_line" /> <td - v-once :class="line.type" class="line_content" v-html="line.richText" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 9fd19b74cd7..e7d789734c3 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -20,8 +20,11 @@ export default { }, }, computed: { - ...mapGetters('diffs', ['commitId']), - ...mapGetters(['discussionsByLineCode']), + ...mapGetters('diffs', [ + 'commitId', + 'shouldRenderInlineCommentRow', + 'singleDiscussionByLineCode', + ]), ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), @@ -36,15 +39,8 @@ export default { }, }, methods: { - shouldRenderCommentRow(line) { - if (this.diffLineCommentForms[line.lineCode]) return true; - - const lineDiscussions = this.discussionsByLineCode[line.lineCode]; - if (lineDiscussions === undefined) { - return false; - } - - return lineDiscussions.every(discussion => discussion.expanded); + discussionsList(line) { + return line.lineCode !== undefined ? this.singleDiscussionByLineCode(line.lineCode) : []; }, }, }; @@ -65,13 +61,15 @@ export default { :line="line" :is-bottom="index + 1 === diffLinesLength" :key="line.lineCode" + :discussions="discussionsList(line)" /> <inline-diff-comment-row - v-if="shouldRenderCommentRow(line)" + v-if="shouldRenderInlineCommentRow(line)" :diff-file-hash="diffFile.fileHash" :line="line" :line-index="index" :key="index" + :discussions="discussionsList(line)" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index cc5248c25d9..48b8feeb0b4 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapGetters } from 'vuex'; +import { mapState } from 'vuex'; import diffDiscussions from './diff_discussions.vue'; import diffLineNoteForm from './diff_line_note_form.vue'; @@ -21,30 +21,34 @@ export default { type: Number, required: true, }, + leftDiscussions: { + type: Array, + required: false, + default: () => [], + }, + rightDiscussions: { + type: Array, + required: false, + default: () => [], + }, }, computed: { ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), - ...mapGetters(['discussionsByLineCode']), leftLineCode() { return this.line.left.lineCode; }, rightLineCode() { return this.line.right.lineCode; }, - hasDiscussion() { - const discussions = this.discussionsByLineCode; - - return discussions[this.leftLineCode] || discussions[this.rightLineCode]; - }, hasExpandedDiscussionOnLeft() { - const discussions = this.discussionsByLineCode[this.leftLineCode]; + const discussions = this.leftDiscussions; return discussions ? discussions.every(discussion => discussion.expanded) : false; }, hasExpandedDiscussionOnRight() { - const discussions = this.discussionsByLineCode[this.rightLineCode]; + const discussions = this.rightDiscussions; return discussions ? discussions.every(discussion => discussion.expanded) : false; }, @@ -52,17 +56,18 @@ export default { return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; }, shouldRenderDiscussionsOnLeft() { - return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft; + return this.leftDiscussions && this.hasExpandedDiscussionOnLeft; }, shouldRenderDiscussionsOnRight() { - return ( - this.discussionsByLineCode[this.rightLineCode] && - this.hasExpandedDiscussionOnRight && - this.line.right.type - ); + return this.rightDiscussions && this.hasExpandedDiscussionOnRight && this.line.right.type; + }, + showRightSideCommentForm() { + return this.line.right.type && this.diffLineCommentForms[this.rightLineCode]; }, className() { - return this.hasDiscussion ? '' : 'js-temp-notes-holder'; + return this.leftDiscussions.length > 0 || this.rightDiscussions.length > 0 + ? '' + : 'js-temp-notes-holder'; }, }, }; @@ -80,13 +85,12 @@ export default { class="content" > <diff-discussions - v-if="discussionsByLineCode[leftLineCode].length" - :discussions="discussionsByLineCode[leftLineCode]" + v-if="leftDiscussions.length" + :discussions="leftDiscussions" /> </div> <diff-line-note-form - v-if="diffLineCommentForms[leftLineCode] && - diffLineCommentForms[leftLineCode]" + v-if="diffLineCommentForms[leftLineCode]" :diff-file-hash="diffFileHash" :line="line.left" :note-target-line="line.left" @@ -100,13 +104,12 @@ export default { class="content" > <diff-discussions - v-if="discussionsByLineCode[rightLineCode].length" - :discussions="discussionsByLineCode[rightLineCode]" + v-if="rightDiscussions.length" + :discussions="rightDiscussions" /> </div> <diff-line-note-form - v-if="diffLineCommentForms[rightLineCode] && - diffLineCommentForms[rightLineCode] && line.right.type" + v-if="showRightSideCommentForm" :diff-file-hash="diffFileHash" :line="line.right" :note-target-line="line.right" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index ee5bb4d8d05..d4e54c2bd00 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -36,6 +36,16 @@ export default { required: false, default: false, }, + leftDiscussions: { + type: Array, + required: false, + default: () => [], + }, + rightDiscussions: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -116,10 +126,10 @@ export default { :is-hover="isLeftHover" :show-comment-button="true" :diff-view-type="parallelDiffViewType" + :discussions="leftDiscussions" class="diff-line-num old_line" /> <td - v-once :id="line.left.lineCode" :class="parallelViewLeftLineType" class="line_content parallel left-side" @@ -137,10 +147,10 @@ export default { :is-hover="isRightHover" :show-comment-button="true" :diff-view-type="parallelDiffViewType" + :discussions="rightDiscussions" class="diff-line-num new_line" /> <td - v-once :id="line.right.lineCode" :class="line.right.type" class="line_content parallel right-side" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 32528c9e7ab..24ceb52a04a 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -21,8 +21,11 @@ export default { }, }, computed: { - ...mapGetters('diffs', ['commitId']), - ...mapGetters(['discussionsByLineCode']), + ...mapGetters('diffs', [ + 'commitId', + 'singleDiscussionByLineCode', + 'shouldRenderParallelCommentRow', + ]), ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), @@ -53,29 +56,9 @@ export default { }, }, methods: { - shouldRenderCommentRow(line) { - const leftLineCode = line.left.lineCode; - const rightLineCode = line.right.lineCode; - const discussions = this.discussionsByLineCode; - const leftDiscussions = discussions[leftLineCode]; - const rightDiscussions = discussions[rightLineCode]; - const hasDiscussion = leftDiscussions || rightDiscussions; - - const hasExpandedDiscussionOnLeft = leftDiscussions - ? leftDiscussions.every(discussion => discussion.expanded) - : false; - const hasExpandedDiscussionOnRight = rightDiscussions - ? rightDiscussions.every(discussion => discussion.expanded) - : false; - - if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) { - return true; - } - - const hasCommentFormOnLeft = this.diffLineCommentForms[leftLineCode]; - const hasCommentFormOnRight = this.diffLineCommentForms[rightLineCode]; - - return hasCommentFormOnLeft || hasCommentFormOnRight; + discussionsByLine(line, leftOrRight) { + return line[leftOrRight] && line[leftOrRight].lineCode !== undefined ? + this.singleDiscussionByLineCode(line[leftOrRight].lineCode) : []; }, }, }; @@ -98,13 +81,17 @@ export default { :line="line" :is-bottom="index + 1 === diffLinesLength" :key="index" + :left-discussions="discussionsByLine(line, 'left')" + :right-discussions="discussionsByLine(line, 'right')" /> <parallel-diff-comment-row - v-if="shouldRenderCommentRow(line)" + v-if="shouldRenderParallelCommentRow(line)" :key="`dcr-${index}`" :line="line" :diff-file-hash="diffFile.fileHash" :line-index="index" + :left-discussions="discussionsByLine(line, 'left')" + :right-discussions="discussionsByLine(line, 'right')" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 9aec117c236..4a47646d7fa 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -64,6 +64,47 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = discussion.diff_discussion && _.isEqual(discussion.diff_file.file_hash, diff.fileHash), ) || []; +export const singleDiscussionByLineCode = (state, getters, rootState, rootGetters) => lineCode => { + if (!lineCode || lineCode === undefined) return []; + const discussions = rootGetters.discussionsByLineCode; + return discussions[lineCode] || []; +}; + +export const shouldRenderParallelCommentRow = (state, getters) => line => { + const leftLineCode = line.left.lineCode; + const rightLineCode = line.right.lineCode; + const leftDiscussions = getters.singleDiscussionByLineCode(leftLineCode); + const rightDiscussions = getters.singleDiscussionByLineCode(rightLineCode); + const hasDiscussion = leftDiscussions.length || rightDiscussions.length; + + const hasExpandedDiscussionOnLeft = leftDiscussions.length + ? leftDiscussions.every(discussion => discussion.expanded) + : false; + const hasExpandedDiscussionOnRight = rightDiscussions.length + ? rightDiscussions.every(discussion => discussion.expanded) + : false; + + if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) { + return true; + } + + const hasCommentFormOnLeft = state.diffLineCommentForms[leftLineCode]; + const hasCommentFormOnRight = state.diffLineCommentForms[rightLineCode]; + + return hasCommentFormOnLeft || hasCommentFormOnRight; +}; + +export const shouldRenderInlineCommentRow = (state, getters) => line => { + if (state.diffLineCommentForms[line.lineCode]) return true; + + const lineDiscussions = getters.singleDiscussionByLineCode(line.lineCode); + if (lineDiscussions.length === 0) { + return false; + } + + return lineDiscussions.every(discussion => discussion.expanded); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma∂ tests export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.fileHash === fileHash); diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index d9589baa76e..82082ac508a 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -173,3 +173,24 @@ export function trimFirstCharOfLineContent(line = {}) { return parsedLine; } + +export function getDiffRefsByLineCode(diffFiles) { + return diffFiles.reduce((acc, diffFile) => { + const { baseSha, headSha, startSha } = diffFile.diffRefs; + const { newPath, oldPath } = diffFile; + + // We can only use highlightedDiffLines to create the map of diff lines because + // highlightedDiffLines will also include every parallel diff line in it. + if (diffFile.highlightedDiffLines) { + diffFile.highlightedDiffLines.forEach(line => { + const { lineCode, oldLine, newLine } = line; + + if (lineCode) { + acc[lineCode] = { baseSha, headSha, startSha, newPath, oldPath, oldLine, newLine }; + } + }); + } + + return acc; + }, {}); +} diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 21cf92d1bc5..11e3b781e5a 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -116,7 +116,8 @@ export default { this.model && this.hasLastDeploymentKey && this.model.last_deployment && - this.model.last_deployment.deployable + this.model.last_deployment.deployable && + this.model.last_deployment.deployable.retry_path ); }, diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 8d231e6c405..c3959ef3e9e 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */ +/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */ /* global fuzzaldrinPlus */ import $ from 'jquery'; @@ -19,32 +19,42 @@ GitLabDropdownInput = (function() { this.fieldName = this.options.fieldName || 'field-name'; $inputContainer = this.input.parent(); $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', (function(_this) { - // Clear click - return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.input.val('').trigger('input').focus(); - }; - })(this)); + $clearButton.on( + 'click', + (function(_this) { + // Clear click + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.input + .val('') + .trigger('input') + .focus(); + }; + })(this), + ); this.input - .on('keydown', function (e) { - var keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', function(e) { - var val = e.currentTarget.value || _this.options.inputFieldName; - val = val.split(' ').join('-') // replaces space with dash - .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() // replace non alphanumeric - .replace(/(-)\1+/g, '-'); // replace repeated dashes - _this.cb(_this.options.fieldName, val, {}, true); - _this.input.closest('.dropdown') - .find('.dropdown-toggle-text') - .text(val); - }); + .on('keydown', function(e) { + var keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', function(e) { + var val = e.currentTarget.value || _this.options.inputFieldName; + val = val + .split(' ') + .join('-') // replaces space with dash + .replace(/[^a-zA-Z0-9 -]/g, '') + .toLowerCase() // replace non alphanumeric + .replace(/(-)\1+/g, '-'); // replace repeated dashes + _this.cb(_this.options.fieldName, val, {}, true); + _this.input + .closest('.dropdown') + .find('.dropdown-toggle-text') + .text(val); + }); } GitLabDropdownInput.prototype.onInput = function(cb) { @@ -61,7 +71,7 @@ GitLabDropdownFilter = (function() { ARROW_KEY_CODES = [38, 40]; - HAS_VALUE_CLASS = "has-value"; + HAS_VALUE_CLASS = 'has-value'; function GitLabDropdownFilter(input, options) { var $clearButton, $inputContainer, ref, timeout; @@ -70,44 +80,59 @@ GitLabDropdownFilter = (function() { this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; $inputContainer = this.input.parent(); $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', (function(_this) { - // Clear click - return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.input.val('').trigger('input').focus(); - }; - })(this)); + $clearButton.on( + 'click', + (function(_this) { + // Clear click + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.input + .val('') + .trigger('input') + .focus(); + }; + })(this), + ); // Key events - timeout = ""; + timeout = ''; this.input - .on('keydown', function (e) { + .on('keydown', function(e) { var keyCode = e.which; if (keyCode === 13 && !options.elIsInput) { e.preventDefault(); } }) - .on('input', function() { - if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.removeClass(HAS_VALUE_CLASS); - } - // Only filter asynchronously only if option remote is set - if (this.options.remote) { - clearTimeout(timeout); - return timeout = setTimeout(function() { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query(this.input.val(), function(data) { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }.bind(this)); - }.bind(this), 250); - } else { - return this.filter(this.input.val()); - } - }.bind(this)); + .on( + 'input', + function() { + if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.addClass(HAS_VALUE_CLASS); + } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.removeClass(HAS_VALUE_CLASS); + } + // Only filter asynchronously only if option remote is set + if (this.options.remote) { + clearTimeout(timeout); + return (timeout = setTimeout( + function() { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query( + this.input.val(), + function(data) { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); + }.bind(this), + ); + }.bind(this), + 250, + )); + } else { + return this.filter(this.input.val()); + } + }.bind(this), + ); } GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { @@ -120,7 +145,7 @@ GitLabDropdownFilter = (function() { this.options.onFilter(search_text); } data = this.options.data(); - if ((data != null) && !this.options.filterByText) { + if (data != null && !this.options.filterByText) { results = data; if (search_text !== '') { // When data is an array of objects therefore [object Array] e.g. @@ -130,7 +155,7 @@ GitLabDropdownFilter = (function() { // ] if (_.isArray(data)) { results = fuzzaldrinPlus.filter(data, search_text, { - key: this.options.keys + key: this.options.keys, }); } else { // If data is grouped therefore an [object Object]. e.g. @@ -149,7 +174,7 @@ GitLabDropdownFilter = (function() { for (key in data) { group = data[key]; tmp = fuzzaldrinPlus.filter(group, search_text, { - key: this.options.keys + key: this.options.keys, }); if (tmp.length) { results[key] = tmp.map(function(item) { @@ -180,7 +205,10 @@ GitLabDropdownFilter = (function() { elements.show().removeClass('option-hidden'); } - elements.parent().find('.dropdown-menu-empty-item').toggleClass('hidden', elements.is(':visible')); + elements + .parent() + .find('.dropdown-menu-empty-item') + .toggleClass('hidden', elements.is(':visible')); } }; @@ -194,23 +222,26 @@ GitLabDropdownRemote = (function() { } GitLabDropdownRemote.prototype.execute = function() { - if (typeof this.dataEndpoint === "string") { + if (typeof this.dataEndpoint === 'string') { return this.fetchData(); - } else if (typeof this.dataEndpoint === "function") { + } else if (typeof this.dataEndpoint === 'function') { if (this.options.beforeSend) { this.options.beforeSend(); } - return this.dataEndpoint("", (function(_this) { - // Fetch the data by calling the data funcfion - return function(data) { - if (_this.options.success) { - _this.options.success(data); - } - if (_this.options.beforeSend) { - return _this.options.beforeSend(); - } - }; - })(this)); + return this.dataEndpoint( + '', + (function(_this) { + // Fetch the data by calling the data funcfion + return function(data) { + if (_this.options.success) { + _this.options.success(data); + } + if (_this.options.beforeSend) { + return _this.options.beforeSend(); + } + }; + })(this), + ); } }; @@ -220,33 +251,41 @@ GitLabDropdownRemote = (function() { } // Fetch the data through ajax if the data is a string - return axios.get(this.dataEndpoint) - .then(({ data }) => { - if (this.options.success) { - return this.options.success(data); - } - }); + return axios.get(this.dataEndpoint).then(({ data }) => { + if (this.options.success) { + return this.options.success(data); + } + }); }; return GitLabDropdownRemote; })(); GitLabDropdown = (function() { - var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; + var ACTIVE_CLASS, + FILTER_INPUT, + NO_FILTER_INPUT, + INDETERMINATE_CLASS, + LOADING_CLASS, + PAGE_TWO_CLASS, + NON_SELECTABLE_CLASSES, + SELECTABLE_CLASSES, + CURSOR_SELECT_SCROLL_PADDING, + currentIndex; - LOADING_CLASS = "is-loading"; + LOADING_CLASS = 'is-loading'; - PAGE_TWO_CLASS = "is-page-two"; + PAGE_TWO_CLASS = 'is-page-two'; - ACTIVE_CLASS = "is-active"; + ACTIVE_CLASS = 'is-active'; - INDETERMINATE_CLASS = "is-indeterminate"; + INDETERMINATE_CLASS = 'is-indeterminate'; currentIndex = -1; NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; - SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)"; + SELECTABLE_CLASSES = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ', .option-hidden)'; CURSOR_SELECT_SCROLL_PADDING = 5; @@ -263,15 +302,15 @@ GitLabDropdown = (function() { this.opened = this.opened.bind(this); this.shouldPropagate = this.shouldPropagate.bind(this); self = this; - selector = $(this.el).data("target"); + selector = $(this.el).data('target'); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); // Set Defaults this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); this.highlight = !!this.options.highlight; - this.filterInputBlur = this.options.filterInputBlur != null - ? this.options.filterInputBlur - : true; + this.icon = !!this.options.icon; + this.filterInputBlur = + this.options.filterInputBlur != null ? this.options.filterInputBlur : true; // If no input is passed create a default one self = this; // If selector was passed @@ -296,11 +335,17 @@ GitLabDropdown = (function() { _this.fullData = data; _this.parseData(_this.fullData); _this.focusTextInput(); - if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') { + if ( + _this.options.filterable && + _this.filter && + _this.filter.input && + _this.filter.input.val() && + _this.filter.input.val().trim() !== '' + ) { return _this.filter.input.trigger('input'); } }; - // Remote data + // Remote data })(this), instance: this, }); @@ -325,7 +370,7 @@ GitLabDropdown = (function() { return function() { selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = ".dropdown-page-one " + selector; + selector = '.dropdown-page-one ' + selector; } return $(selector, this.instance.dropdown); }; @@ -341,80 +386,97 @@ GitLabDropdown = (function() { if (_this.filterInput.val() !== '') { selector = SELECTABLE_CLASSES; if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = ".dropdown-page-one " + selector; + selector = '.dropdown-page-one ' + selector; } if ($(_this.el).is('input')) { currentIndex = -1; } else { - $(selector, _this.dropdown).first().find('a').addClass('is-focused'); + $(selector, _this.dropdown) + .first() + .find('a') + .addClass('is-focused'); currentIndex = 0; } } }; - })(this) + })(this), }); } // Event listeners - this.dropdown.on("shown.bs.dropdown", this.opened); - this.dropdown.on("hidden.bs.dropdown", this.hidden); - $(this.el).on("update.label", this.updateLabel); - this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate); - this.dropdown.on('keyup', (function(_this) { - return function(e) { - // Escape key - if (e.which === 27) { - return $('.dropdown-menu-close', _this.dropdown).trigger('click'); - } - }; - })(this)); - this.dropdown.on('blur', 'a', (function(_this) { - return function(e) { - var $dropdownMenu, $relatedTarget; - if (e.relatedTarget != null) { - $relatedTarget = $(e.relatedTarget); - $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); - if ($dropdownMenu.length === 0) { - return _this.dropdown.removeClass('show'); + this.dropdown.on('shown.bs.dropdown', this.opened); + this.dropdown.on('hidden.bs.dropdown', this.hidden); + $(this.el).on('update.label', this.updateLabel); + this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); + this.dropdown.on( + 'keyup', + (function(_this) { + return function(e) { + // Escape key + if (e.which === 27) { + return $('.dropdown-menu-close', _this.dropdown).trigger('click'); } - } - }; - })(this)); - if (this.dropdown.find(".dropdown-toggle-page").length) { - this.dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on("click", (function(_this) { + }; + })(this), + ); + this.dropdown.on( + 'blur', + 'a', + (function(_this) { return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.togglePage(); + var $dropdownMenu, $relatedTarget; + if (e.relatedTarget != null) { + $relatedTarget = $(e.relatedTarget); + $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); + if ($dropdownMenu.length === 0) { + return _this.dropdown.removeClass('show'); + } + } }; - })(this)); + })(this), + ); + if (this.dropdown.find('.dropdown-toggle-page').length) { + this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on( + 'click', + (function(_this) { + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.togglePage(); + }; + })(this), + ); } if (this.options.selectable) { - selector = ".dropdown-content a"; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one .dropdown-content a"; + selector = '.dropdown-content a'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = '.dropdown-page-one .dropdown-content a'; } - this.dropdown.on("click", selector, function(e) { - var $el, selected, selectedObj, isMarking; - $el = $(e.currentTarget); - selected = self.rowClicked($el); - selectedObj = selected ? selected[0] : null; - isMarking = selected ? selected[1] : null; - if (this.options.clicked) { - this.options.clicked.call(this, { - selectedObj, - $el, - e, - isMarking, - }); - } + this.dropdown.on( + 'click', + selector, + function(e) { + var $el, selected, selectedObj, isMarking; + $el = $(e.currentTarget); + selected = self.rowClicked($el); + selectedObj = selected ? selected[0] : null; + isMarking = selected ? selected[1] : null; + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); + } - // Update label right after all modifications in dropdown has been done - if (this.options.toggleLabel) { - this.updateLabel(selectedObj, $el, this); - } + // Update label right after all modifications in dropdown has been done + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); + } - $el.trigger('blur'); - }.bind(this)); + $el.trigger('blur'); + }.bind(this), + ); } } @@ -452,10 +514,15 @@ GitLabDropdown = (function() { html = []; for (name in data) { groupData = data[name]; - html.push(this.renderItem({ - header: name - // Add header for each group - }, name)); + html.push( + this.renderItem( + { + header: name, + // Add header for each group + }, + name, + ), + ); this.renderData(groupData, name).map(function(item) { return html.push(item); }); @@ -474,20 +541,25 @@ GitLabDropdown = (function() { if (group == null) { group = false; } - return data.map((function(_this) { - return function(obj, index) { - return _this.renderItem(obj, group, index); - }; - })(this)); + return data.map( + (function(_this) { + return function(obj, index) { + return _this.renderItem(obj, group, index); + }; + })(this), + ); }; GitLabDropdown.prototype.shouldPropagate = function(e) { var $target; if (this.options.multiSelect || this.options.shouldPropagate === false) { $target = $(e.target); - if ($target && !$target.hasClass('dropdown-menu-close') && - !$target.hasClass('dropdown-menu-close-icon') && - !$target.data('isLink')) { + if ( + $target && + !$target.hasClass('dropdown-menu-close') && + !$target.hasClass('dropdown-menu-close-icon') && + !$target.data('isLink') + ) { e.stopPropagation(); return false; } else { @@ -497,9 +569,11 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.filteredFullData = function() { - return this.fullData.filter(r => typeof r === 'object' - && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') - && !Object.prototype.hasOwnProperty.call(r, 'header') + return this.fullData.filter( + r => + typeof r === 'object' && + !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && + !Object.prototype.hasOwnProperty.call(r, 'header'), ); }; @@ -522,11 +596,16 @@ GitLabDropdown = (function() { // matches the correct layout const inputValue = this.filterInput.val(); if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { - this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); + this.options.processData.call( + this.options, + inputValue, + this.filteredFullData(), + this.parseData.bind(this), + ); } contentHtml = $('.dropdown-content', this.dropdown).html(); - if (this.remote && contentHtml === "") { + if (this.remote && contentHtml === '') { this.remote.execute(); } else { this.focusTextInput(); @@ -537,7 +616,11 @@ GitLabDropdown = (function() { } if (this.options.opened) { - this.options.opened.call(this, e); + if (this.options.preserveContext) { + this.options.opened(e); + } else { + this.options.opened.call(this, e); + } } return this.dropdown.trigger('shown.gl.dropdown'); @@ -555,11 +638,11 @@ GitLabDropdown = (function() { var $input; this.resetRows(); this.removeArrayKeyEvent(); - $input = this.dropdown.find(".dropdown-input-field"); + $input = this.dropdown.find('.dropdown-input-field'); if (this.options.filterable) { $input.blur(); } - if (this.dropdown.find(".dropdown-toggle-page").length) { + if (this.dropdown.find('.dropdown-toggle-page').length) { $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); } if (this.options.hidden) { @@ -601,7 +684,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.clearMenu = function() { var selector; selector = '.dropdown-content'; - if (this.dropdown.find(".dropdown-toggle-page").length) { + if (this.dropdown.find('.dropdown-toggle-page').length) { if (this.options.containerSelector) { selector = this.options.containerSelector; } else { @@ -619,7 +702,7 @@ GitLabDropdown = (function() { value = this.options.id ? this.options.id(data) : data.id; if (value) { - value = value.toString().replace(/'/g, '\\\''); + value = value.toString().replace(/'/g, "\\'"); } } @@ -676,21 +759,27 @@ GitLabDropdown = (function() { text = data.text != null ? data.text : ''; } if (this.highlight) { - text = this.highlightTextMatches(text, this.filterInput.val()); + text = data.template + ? this.highlightTemplate(text, data.template) + : this.highlightTextMatches(text, this.filterInput.val()); } // Create the list item & the link var link = document.createElement('a'); link.href = url; - if (this.highlight) { + if (this.icon) { + text = `<span>${text}</span>`; + link.classList.add('d-flex', 'align-items-center'); + link.innerHTML = data.icon ? data.icon + text : text; + } else if (this.highlight) { link.innerHTML = text; } else { link.textContent = text; } if (selected) { - link.className = 'is-active'; + link.classList.add('is-active'); } if (group) { @@ -703,17 +792,24 @@ GitLabDropdown = (function() { return html; }; + GitLabDropdown.prototype.highlightTemplate = function(text, template) { + return `"<b>${_.escape(text)}</b>" ${template}`; + }; + GitLabDropdown.prototype.highlightTextMatches = function(text, term) { const occurrences = fuzzaldrinPlus.match(text, term); const { indexOf } = []; - return text.split('').map(function(character, i) { - if (indexOf.call(occurrences, i) !== -1) { - return "<b>" + character + "</b>"; - } else { - return character; - } - }).join(''); + return text + .split('') + .map(function(character, i) { + if (indexOf.call(occurrences, i) !== -1) { + return '<b>' + character + '</b>'; + } else { + return character; + } + }) + .join(''); }; GitLabDropdown.prototype.noResults = function() { @@ -748,13 +844,15 @@ GitLabDropdown = (function() { } field = []; - value = this.options.id - ? this.options.id(selectedObject, el) - : selectedObject.id; + value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; if (isInput) { field = $(this.el); } else if (value != null) { - field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']"); + field = this.dropdown + .parent() + .find( + "input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, "\\'") + "']", + ); } if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { @@ -780,9 +878,12 @@ GitLabDropdown = (function() { } else { isMarking = true; if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { - this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); + this.dropdown.find('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); if (!isInput) { - this.dropdown.parent().find("input[name='" + fieldName + "']").remove(); + this.dropdown + .parent() + .find("input[name='" + fieldName + "']") + .remove(); } } if (field && field.length && value == null) { @@ -823,13 +924,16 @@ GitLabDropdown = (function() { $('input[name="' + fieldName + '"]').remove(); } - $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value); + $input = $('<input>') + .attr('type', 'hidden') + .attr('name', fieldName) + .val(value); if (this.options.inputId != null) { $input.attr('id', this.options.inputId); } if (this.options.multiSelect) { - Object.keys(selectedObject).forEach((attribute) => { + Object.keys(selectedObject).forEach(attribute => { $input.attr(`data-${attribute}`, selectedObject[attribute]); }); } @@ -844,13 +948,13 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.selectRowAtIndex = function(index) { var $el, selector; // If we pass an option index - if (typeof index !== "undefined") { - selector = SELECTABLE_CLASSES + ":eq(" + index + ") a"; + if (typeof index !== 'undefined') { + selector = SELECTABLE_CLASSES + ':eq(' + index + ') a'; } else { - selector = ".dropdown-content .is-focused"; + selector = '.dropdown-content .is-focused'; } - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one " + selector; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = '.dropdown-page-one ' + selector; } // simulate a click on the first link $el = $(selector, this.dropdown); @@ -867,44 +971,47 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.addArrowKeyEvent = function() { var $input, ARROW_KEY_CODES, selector; ARROW_KEY_CODES = [38, 40]; - $input = this.dropdown.find(".dropdown-input-field"); + $input = this.dropdown.find('.dropdown-input-field'); selector = SELECTABLE_CLASSES; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one " + selector; - } - return $('body').on('keydown', (function(_this) { - return function(e) { - var $listItems, PREV_INDEX, currentKeyCode; - currentKeyCode = e.which; - if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { - e.preventDefault(); - e.stopImmediatePropagation(); - PREV_INDEX = currentIndex; - $listItems = $(selector, _this.dropdown); - // if @options.filterable - // $input.blur() - if (currentKeyCode === 40) { - // Move down - if (currentIndex < ($listItems.length - 1)) { - currentIndex += 1; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = '.dropdown-page-one ' + selector; + } + return $('body').on( + 'keydown', + (function(_this) { + return function(e) { + var $listItems, PREV_INDEX, currentKeyCode; + currentKeyCode = e.which; + if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { + e.preventDefault(); + e.stopImmediatePropagation(); + PREV_INDEX = currentIndex; + $listItems = $(selector, _this.dropdown); + // if @options.filterable + // $input.blur() + if (currentKeyCode === 40) { + // Move down + if (currentIndex < $listItems.length - 1) { + currentIndex += 1; + } + } else if (currentKeyCode === 38) { + // Move up + if (currentIndex > 0) { + currentIndex -= 1; + } } - } else if (currentKeyCode === 38) { - // Move up - if (currentIndex > 0) { - currentIndex -= 1; + if (currentIndex !== PREV_INDEX) { + _this.highlightRowAtIndex($listItems, currentIndex); } + return false; } - if (currentIndex !== PREV_INDEX) { - _this.highlightRowAtIndex($listItems, currentIndex); + if (currentKeyCode === 13 && currentIndex !== -1) { + e.preventDefault(); + _this.selectRowAtIndex(); } - return false; - } - if (currentKeyCode === 13 && currentIndex !== -1) { - e.preventDefault(); - _this.selectRowAtIndex(); - } - }; - })(this)); + }; + })(this), + ); }; GitLabDropdown.prototype.removeArrayKeyEvent = function() { @@ -917,12 +1024,25 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { - var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop; + var $dropdownContent, + $listItem, + dropdownContentBottom, + dropdownContentHeight, + dropdownContentTop, + dropdownScrollTop, + listItemBottom, + listItemHeight, + listItemTop; + + if (!$listItems) { + $listItems = $(SELECTABLE_CLASSES, this.dropdown); + } + // Remove the class for the previously focused row $('.is-focused', this.dropdown).removeClass('is-focused'); // Update the class for the row at the specific index $listItem = $listItems.eq(index); - $listItem.find('a:first-child').addClass("is-focused"); + $listItem.find('a:first-child').addClass('is-focused'); // Dropdown content scroll area $dropdownContent = $listItem.closest('.dropdown-content'); dropdownScrollTop = $dropdownContent.scrollTop(); @@ -936,15 +1056,19 @@ GitLabDropdown = (function() { if (!index) { // Scroll the dropdown content to the top $dropdownContent.scrollTop(0); - } else if (index === ($listItems.length - 1)) { + } else if (index === $listItems.length - 1) { // Scroll the dropdown content to the bottom $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); - } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) { + } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { // Scroll the dropdown content down - $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING); - } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) { + $dropdownContent.scrollTop( + listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING, + ); + } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { // Scroll the dropdown content up - return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING); + return $dropdownContent.scrollTop( + listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING, + ); } }; @@ -965,7 +1089,9 @@ GitLabDropdown = (function() { toggleText = this.options.updateLabel; } - return $(this.el).find(".dropdown-toggle-text").text(toggleText); + return $(this.el) + .find('.dropdown-toggle-text') + .text(toggleText); }; GitLabDropdown.prototype.clearField = function(field, isInput) { diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index efbf2e3a295..2b9e2a929fc 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -78,17 +78,10 @@ export default { > <div :class="{ 'project-row-contents': !isGroup }" - class="group-row-contents"> - <item-actions - v-if="isGroup" - :group="group" - :parent-group="parentGroup" - /> - <item-stats - :item="group" - /> + class="group-row-contents d-flex justify-content-end align-items-center" + > <div - class="folder-toggle-wrap" + class="folder-toggle-wrap append-right-4 d-flex align-items-center" > <item-caret :is-group-open="group.isOpen" @@ -100,7 +93,7 @@ export default { </div> <div :class="{ 'content-loading': group.isChildrenLoading }" - class="avatar-container prepend-top-8 prepend-left-5 s24 d-none d-sm-block" + class="avatar-container s24 d-none d-sm-block" > <a :href="group.relativePath" @@ -120,32 +113,46 @@ export default { </a> </div> <div - class="title namespace-title" + class="group-text flex-grow" > - <a - v-tooltip - :href="group.relativePath" - :title="group.fullName" - class="no-expand" - data-placement="bottom" - >{{ - // ending bracket must be by closing tag to prevent - // link hover text-decoration from over-extending - group.name - }}</a> - <span - v-if="group.permission" - class="user-access-role" + <div + class="title namespace-title append-right-8" > - {{ group.permission }} - </span> - </div> - <div - v-if="group.description" - class="description"> - <span v-html="group.description"> - </span> + <a + v-tooltip + :href="group.relativePath" + :title="group.fullName" + class="no-expand" + data-placement="bottom" + >{{ + // ending bracket must be by closing tag to prevent + // link hover text-decoration from over-extending + group.name + }}</a> + <span + v-if="group.permission" + class="user-access-role" + > + {{ group.permission }} + </span> + </div> + <div + v-if="group.description" + class="description" + > + <span v-html="group.description"> + </span> + </div> </div> + <item-stats + :item="group" + class="group-stats prepend-top-2" + /> + <item-actions + v-if="isGroup" + :group="group" + :parent-group="parentGroup" + /> </div> <group-folder v-if="group.isOpen && hasChildren" diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 62697e0ecc3..2cebacc1c4c 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -13,11 +13,8 @@ export default { tooltip, }, computed: { - ...mapGetters(['currentProject', 'hasChanges']), + ...mapGetters(['hasChanges']), ...mapState(['currentActivityView']), - goBackUrl() { - return document.referrer || this.currentProject.web_url; - }, }, methods: { ...mapActions(['updateActivityBarView']), @@ -36,22 +33,6 @@ export default { <template> <nav class="ide-activity-bar"> <ul class="list-unstyled"> - <li v-once> - <a - v-tooltip - :href="goBackUrl" - :title="s__('IDE|Go back')" - :aria-label="s__('IDE|Go back')" - data-container="body" - data-placement="right" - class="ide-sidebar-link" - > - <icon - :size="16" - name="go-back" - /> - </a> - </li> <li> <button v-tooltip diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue new file mode 100644 index 00000000000..cc3e84e3f77 --- /dev/null +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -0,0 +1,60 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import router from '../../ide_router'; + +export default { + components: { + Icon, + Timeago, + }, + props: { + item: { + type: Object, + required: true, + }, + projectId: { + type: String, + required: true, + }, + isActive: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + branchHref() { + return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href; + }, + }, +}; +</script> + +<template> + <a + :href="branchHref" + class="btn-link d-flex align-items-center" + > + <span class="d-flex append-right-default ide-search-list-current-icon"> + <icon + v-if="isActive" + :size="18" + name="mobile-issue-close" + /> + </span> + <span> + <strong> + {{ item.name }} + </strong> + <span + class="ide-merge-request-project-path d-block mt-1" + > + Updated + <timeago + :time="item.committedDate || ''" + /> + </span> + </span> + </a> +</template> diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue new file mode 100644 index 00000000000..6db7b9d6b0e --- /dev/null +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -0,0 +1,111 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import _ from 'underscore'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import Item from './item.vue'; + +export default { + components: { + LoadingIcon, + Item, + Icon, + }, + data() { + return { + search: '', + }; + }, + computed: { + ...mapState('branches', ['branches', 'isLoading']), + ...mapState(['currentBranchId', 'currentProjectId']), + hasBranches() { + return this.branches.length !== 0; + }, + hasNoSearchResults() { + return this.search !== '' && !this.hasBranches; + }, + }, + watch: { + isLoading: { + handler: 'focusSearch', + }, + }, + mounted() { + this.loadBranches(); + }, + methods: { + ...mapActions('branches', ['fetchBranches']), + loadBranches() { + this.fetchBranches({ search: this.search }); + }, + searchBranches: _.debounce(function debounceSearch() { + this.loadBranches(); + }, 250), + focusSearch() { + if (!this.isLoading) { + this.$nextTick(() => { + this.$refs.searchInput.focus(); + }); + } + }, + isActiveBranch(item) { + return item.name === this.currentBranchId; + }, + }, +}; +</script> + +<template> + <div> + <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom"> + <div class="position-relative"> + <input + ref="searchInput" + :placeholder="__('Search branches')" + v-model="search" + type="search" + class="form-control dropdown-input-field" + @input="searchBranches" + /> + <icon + :size="18" + name="search" + class="input-icon" + /> + </div> + </div> + <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> + <loading-icon + v-if="isLoading" + class="mt-3 mb-3 align-self-center ml-auto mr-auto" + size="2" + /> + <ul + v-else + class="mb-3 w-100" + > + <template v-if="hasBranches"> + <li + v-for="item in branches" + :key="item.name" + > + <item + :item="item" + :project-id="currentProjectId" + :is-active="isActiveBranch(item)" + /> + </li> + </template> + <li + v-else + class="ide-search-list-empty d-flex align-items-center justify-content-center" + > + <template v-if="hasNoSearchResults"> + {{ __('No branches found') }} + </template> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue new file mode 100644 index 00000000000..6cf190288e8 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_header.vue @@ -0,0 +1,37 @@ +<script> +import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; + +export default { + components: { + ProjectAvatarDefault, + }, + props: { + project: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="context-header ide-context-header"> + <a + :href="project.web_url" + :title="s__('IDE|Go to project')" + > + <project-avatar-default + :project="project" + :size="48" + /> + <span class="ide-sidebar-project-title"> + <span class="sidebar-context-title"> + {{ project.name }} + </span> + <span class="sidebar-context-title text-secondary"> + {{ project.path_with_namespace }} + </span> + </span> + </a> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 21906674c4b..4771c58a11d 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -1,12 +1,6 @@ <script> -import $ from 'jquery'; import { mapState, mapGetters } from 'vuex'; -import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; -import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -import Identicon from '../../vue_shared/components/identicon.vue'; import IdeTree from './ide_tree.vue'; import ResizablePanel from './resizable_panel.vue'; import ActivityBar from './activity_bar.vue'; @@ -14,43 +8,28 @@ import CommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; import SuccessMessage from './commit_sidebar/success_message.vue'; -import MergeRequestDropdown from './merge_requests/dropdown.vue'; +import IdeProjectHeader from './ide_project_header.vue'; import { activityBarViews } from '../constants'; export default { - directives: { - tooltip, - }, components: { - Icon, - PanelResizer, SkeletonLoadingContainer, ResizablePanel, ActivityBar, - ProjectAvatarImage, - Identicon, CommitSection, IdeTree, CommitForm, IdeReview, SuccessMessage, - MergeRequestDropdown, - }, - data() { - return { - showTooltip: false, - showMergeRequestsDropdown: false, - }; + IdeProjectHeader, }, computed: { ...mapState([ 'loading', - 'currentBranchId', 'currentActivityView', 'changedFiles', 'stagedFiles', 'lastCommitMsg', - 'currentMergeRequestId', ]), ...mapGetters(['currentProject', 'someUncommitedChanges']), showSuccessMessage() { @@ -59,46 +38,6 @@ export default { (this.lastCommitMsg && !this.someUncommitedChanges) ); }, - branchTooltipTitle() { - return this.showTooltip ? this.currentBranchId : undefined; - }, - }, - watch: { - currentBranchId() { - this.$nextTick(() => { - if (!this.$refs.branchId) return; - - this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth; - }); - }, - loading() { - this.$nextTick(() => { - this.addDropdownListeners(); - }); - }, - }, - mounted() { - this.addDropdownListeners(); - }, - beforeDestroy() { - $(this.$refs.mergeRequestDropdown) - .off('show.bs.dropdown') - .off('hide.bs.dropdown'); - }, - methods: { - addDropdownListeners() { - if (!this.$refs.mergeRequestDropdown) return; - - $(this.$refs.mergeRequestDropdown) - .on('show.bs.dropdown', () => { - this.toggleMergeRequestDropdown(); - }).on('hide.bs.dropdown', () => { - this.toggleMergeRequestDropdown(); - }); - }, - toggleMergeRequestDropdown() { - this.showMergeRequestsDropdown = !this.showMergeRequestsDropdown; - }, }, }; </script> @@ -108,12 +47,10 @@ export default { :collapsible="false" :initial-width="340" side="left" + class="flex-column" > - <activity-bar - v-if="!loading" - /> - <div class="multi-file-commit-panel-inner"> - <template v-if="loading"> + <template v-if="loading"> + <div class="multi-file-commit-panel-inner"> <div v-for="n in 3" :key="n" @@ -121,81 +58,23 @@ export default { > <skeleton-loading-container /> </div> - </template> - <template v-else> - <div - ref="mergeRequestDropdown" - class="context-header ide-context-header dropdown" - > - <button - type="button" - data-toggle="dropdown" - > - <div - v-if="currentProject.avatar_url" - class="avatar-container s40 project-avatar" - > - <project-avatar-image - :link-href="currentProject.path" - :img-src="currentProject.avatar_url" - :img-alt="currentProject.name" - :img-size="40" - class="avatar-container project-avatar" - /> - </div> - <identicon - v-else - :entity-id="currentProject.id" - :entity-name="currentProject.name" - size-class="s40" + </div> + </template> + <template v-else> + <ide-project-header + :project="currentProject" + /> + <div class="ide-context-body d-flex flex-fill"> + <activity-bar /> + <div class="multi-file-commit-panel-inner"> + <div class="multi-file-commit-panel-inner-content"> + <component + :is="currentActivityView" /> - <div class="ide-sidebar-project-title"> - <div class="sidebar-context-title"> - {{ currentProject.name }} - </div> - <div class="d-flex"> - <div - v-tooltip - v-if="currentBranchId" - ref="branchId" - :title="branchTooltipTitle" - class="sidebar-context-title ide-sidebar-branch-title" - > - <icon - name="branch" - css-classes="append-right-5" - />{{ currentBranchId }} - </div> - <div - v-if="currentMergeRequestId" - :class="{ - 'prepend-left-8': currentBranchId - }" - class="sidebar-context-title ide-sidebar-branch-title" - > - <icon - name="git-merge" - css-classes="append-right-5" - />!{{ currentMergeRequestId }} - </div> - </div> - </div> - <icon - class="ml-auto" - name="chevron-down" - /> - </button> - <merge-request-dropdown - :show="showMergeRequestsDropdown" - /> - </div> - <div class="multi-file-commit-panel-inner-scroll"> - <component - :is="currentActivityView" - /> + </div> + <commit-form /> </div> - <commit-form /> - </template> - </div> + </div> + </template> </resizable-panel> </template> diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index e996dd9059e..39d46a91731 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -35,14 +35,13 @@ export default { <template> <ide-tree-list - header-class="d-flex w-100" viewer-type="editor" > <template slot="header" > {{ __('Edit') }} - <div class="ml-auto d-flex"> + <div class="ide-tree-actions ml-auto d-flex"> <new-entry-button :label="__('New file')" :show-label="false" diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 2e7226b727c..5611b37be7c 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -3,14 +3,14 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; import RepoFile from './repo_file.vue'; -import NewDropdown from './new_dropdown/index.vue'; +import NavDropdown from './nav_dropdown.vue'; export default { components: { Icon, RepoFile, SkeletonLoadingContainer, - NewDropdown, + NavDropdown, }, props: { viewerType: { @@ -57,14 +57,19 @@ export default { :class="headerClass" class="ide-tree-header" > + <nav-dropdown /> <slot name="header"></slot> </header> - <repo-file - v-for="file in currentTree.tree" - :key="file.key" - :file="file" - :level="0" - /> + <div + class="ide-tree-body" + > + <repo-file + v-for="file in currentTree.tree" + :key="file.key" + :file="file" + :level="0" + /> + </div> </template> </div> </template> diff --git a/app/assets/javascripts/ide/components/merge_requests/dropdown.vue b/app/assets/javascripts/ide/components/merge_requests/dropdown.vue deleted file mode 100644 index 4b9824bf04b..00000000000 --- a/app/assets/javascripts/ide/components/merge_requests/dropdown.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { mapGetters } from 'vuex'; -import Tabs from '../../../vue_shared/components/tabs/tabs'; -import Tab from '../../../vue_shared/components/tabs/tab.vue'; -import List from './list.vue'; - -export default { - components: { - Tabs, - Tab, - List, - }, - props: { - show: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapGetters('mergeRequests', ['assignedData', 'createdData']), - createdMergeRequestLength() { - return this.createdData.mergeRequests.length; - }, - assignedMergeRequestLength() { - return this.assignedData.mergeRequests.length; - }, - }, -}; -</script> - -<template> - <div class="dropdown-menu ide-merge-requests-dropdown p-0"> - <tabs - v-if="show" - stop-propagation - > - <tab active> - <template slot="title"> - {{ __('Created by me') }} - <span class="badge badge-pill"> - {{ createdMergeRequestLength }} - </span> - </template> - <list - :empty-text="__('You have not created any merge requests')" - type="created" - /> - </tab> - <tab> - <template slot="title"> - {{ __('Assigned to me') }} - <span class="badge badge-pill"> - {{ assignedMergeRequestLength }} - </span> - </template> - <list - :empty-text="__('You do not have any assigned merge requests')" - type="assigned" - /> - </tab> - </tabs> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 4e18376bd48..0c4ea80ba08 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -1,5 +1,6 @@ <script> import Icon from '../../../vue_shared/components/icon.vue'; +import router from '../../ide_router'; export default { components: { @@ -29,22 +30,21 @@ export default { pathWithID() { return `${this.item.projectPathWithNamespace}!${this.item.iid}`; }, - }, - methods: { - clickItem() { - this.$emit('click', this.item); + mergeRequestHref() { + const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`; + + return router.resolve(path).href; }, }, }; </script> <template> - <button - type="button" + <a + :href="mergeRequestHref" class="btn-link d-flex align-items-center" - @click="clickItem" > - <span class="d-flex append-right-default ide-merge-request-current-icon"> + <span class="d-flex append-right-default ide-search-list-current-icon"> <icon v-if="isActive" :size="18" @@ -59,5 +59,5 @@ export default { {{ pathWithID }} </span> </span> - </button> + </a> </template> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 19d3e48ee10..fc612956688 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -1,96 +1,101 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; -import LoadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import Item from './item.vue'; +import TokenedInput from '../shared/tokened_input.vue'; + +const SEARCH_TYPES = [ + { type: 'created', label: __('Created by me') }, + { type: 'assigned', label: __('Assigned to me') }, +]; export default { components: { LoadingIcon, + TokenedInput, Item, - }, - props: { - type: { - type: String, - required: true, - }, - emptyText: { - type: String, - required: true, - }, + Icon, }, data() { return { search: '', + currentSearchType: null, + hasSearchFocus: false, }; }, computed: { - ...mapGetters('mergeRequests', ['getData']), + ...mapState('mergeRequests', ['mergeRequests', 'isLoading']), ...mapState(['currentMergeRequestId', 'currentProjectId']), - data() { - return this.getData(this.type); - }, - isLoading() { - return this.data.isLoading; - }, - mergeRequests() { - return this.data.mergeRequests; - }, hasMergeRequests() { return this.mergeRequests.length !== 0; }, hasNoSearchResults() { return this.search !== '' && !this.hasMergeRequests; }, + showSearchTypes() { + return this.hasSearchFocus && !this.search && !this.currentSearchType; + }, + type() { + return this.currentSearchType + ? this.currentSearchType.type + : ''; + }, + searchTokens() { + return this.currentSearchType + ? [this.currentSearchType] + : []; + }, }, watch: { - isLoading: { - handler: 'focusSearch', + search() { + // When the search is updated, let's turn off this flag to hide the search types + this.hasSearchFocus = false; }, }, mounted() { this.loadMergeRequests(); }, methods: { - ...mapActions('mergeRequests', ['fetchMergeRequests', 'openMergeRequest']), + ...mapActions('mergeRequests', ['fetchMergeRequests']), loadMergeRequests() { this.fetchMergeRequests({ type: this.type, search: this.search }); }, - viewMergeRequest(item) { - this.openMergeRequest({ - projectPath: item.projectPathWithNamespace, - id: item.iid, - }); - }, searchMergeRequests: _.debounce(function debounceSearch() { this.loadMergeRequests(); }, 250), - focusSearch() { - if (!this.isLoading) { - this.$nextTick(() => { - this.$refs.searchInput.focus(); - }); - } + onSearchFocus() { + this.hasSearchFocus = true; + }, + setSearchType(searchType) { + this.currentSearchType = searchType; + this.loadMergeRequests(); }, }, + searchTypes: SEARCH_TYPES, }; </script> <template> <div> <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom"> - <input - ref="searchInput" - :placeholder="__('Search merge requests')" - v-model="search" - type="search" - class="dropdown-input-field" - @input="searchMergeRequests" - /> - <i - aria-hidden="true" - class="fa fa-search dropdown-input-search" - ></i> + <div class="position-relative"> + <tokened-input + v-model="search" + :tokens="searchTokens" + :placeholder="__('Search merge requests')" + @focus="onSearchFocus" + @input="searchMergeRequests" + @removeToken="setSearchType(null)" + /> + <icon + :size="18" + name="search" + class="input-icon" + /> + </div> </div> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <loading-icon @@ -98,35 +103,52 @@ export default { class="mt-3 mb-3 align-self-center ml-auto mr-auto" size="2" /> - <ul - v-else - class="mb-3 w-100" - > - <template v-if="hasMergeRequests"> - <li - v-for="item in mergeRequests" - :key="item.id" - > - <item - :item="item" - :current-id="currentMergeRequestId" - :current-project-id="currentProjectId" - @click="viewMergeRequest" - /> - </li> - </template> - <li - v-else - class="ide-merge-requests-empty d-flex align-items-center justify-content-center" + <template v-else> + <ul + class="mb-3 w-100" > - <template v-if="hasNoSearchResults"> - {{ __('No merge requests found') }} + <template v-if="showSearchTypes"> + <li + v-for="searchType in $options.searchTypes" + :key="searchType.type" + > + <button + type="button" + class="btn-link d-flex align-items-center" + @click.stop="setSearchType(searchType)" + > + <span class="d-flex append-right-default ide-search-list-current-icon"> + <icon + :size="18" + name="search" + /> + </span> + <span> + {{ searchType.label }} + </span> + </button> + </li> </template> - <template v-else> - {{ emptyText }} + <template v-else-if="hasMergeRequests"> + <li + v-for="item in mergeRequests" + :key="item.id" + > + <item + :item="item" + :current-id="currentMergeRequestId" + :current-project-id="currentProjectId" + /> + </li> </template> - </li> - </ul> + <li + v-else + class="ide-search-list-empty d-flex align-items-center justify-content-center" + > + {{ __('No merge requests found') }} + </li> + </ul> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue new file mode 100644 index 00000000000..db36779c395 --- /dev/null +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -0,0 +1,59 @@ +<script> +import $ from 'jquery'; +import Icon from '~/vue_shared/components/icon.vue'; +import NavForm from './nav_form.vue'; +import NavDropdownButton from './nav_dropdown_button.vue'; + +export default { + components: { + Icon, + NavDropdownButton, + NavForm, + }, + data() { + return { + isVisibleDropdown: false, + }; + }, + mounted() { + this.addDropdownListeners(); + }, + beforeDestroy() { + this.removeDropdownListeners(); + }, + methods: { + addDropdownListeners() { + $(this.$refs.dropdown) + .on('show.bs.dropdown', () => this.showDropdown()) + .on('hide.bs.dropdown', () => this.hideDropdown()); + }, + removeDropdownListeners() { + $(this.$refs.dropdown) + .off('show.bs.dropdown') + .off('hide.bs.dropdown'); + }, + showDropdown() { + this.isVisibleDropdown = true; + }, + hideDropdown() { + this.isVisibleDropdown = false; + }, + }, +}; +</script> + +<template> + <div + ref="dropdown" + class="btn-group ide-nav-dropdown dropdown" + > + <nav-dropdown-button /> + <div + class="dropdown-menu dropdown-menu-left p-0" + > + <nav-form + v-if="isVisibleDropdown" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue new file mode 100644 index 00000000000..7f98769d484 --- /dev/null +++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue @@ -0,0 +1,54 @@ +<script> +import { mapState } from 'vuex'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +const EMPTY_LABEL = '-'; + +export default { + components: { + Icon, + DropdownButton, + }, + computed: { + ...mapState(['currentBranchId', 'currentMergeRequestId']), + mergeRequestLabel() { + return this.currentMergeRequestId + ? `!${this.currentMergeRequestId}` + : EMPTY_LABEL; + }, + branchLabel() { + return this.currentBranchId || EMPTY_LABEL; + }, + }, +}; +</script> + +<template> + <dropdown-button> + <span + class="row" + > + <span + class="col-7 text-truncate" + > + <icon + :size="16" + :aria-label="__('Current Branch')" + name="branch" + /> + {{ branchLabel }} + </span> + <span + class="col-5 pl-0 text-truncate" + > + <icon + :size="16" + :aria-label="__('Merge Request')" + name="merge-request" + /> + {{ mergeRequestLabel }} + </span> + </span> + </dropdown-button> +</template> diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue new file mode 100644 index 00000000000..718b836e11c --- /dev/null +++ b/app/assets/javascripts/ide/components/nav_form.vue @@ -0,0 +1,40 @@ +<script> +import Tabs from '~/vue_shared/components/tabs/tabs'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; +import BranchesSearchList from './branches/search_list.vue'; +import MergeRequestSearchList from './merge_requests/list.vue'; + +export default { + components: { + Tabs, + Tab, + BranchesSearchList, + MergeRequestSearchList, + }, +}; +</script> + +<template> + <div + class="ide-nav-form p-0" + > + <tabs + stop-propagation + > + <tab + active + > + <template slot="title"> + {{ __('Merge Requests') }} + </template> + <merge-request-search-list /> + </tab> + <tab> + <template slot="title"> + {{ __('Branches') }} + </template> + <branches-search-list /> + </tab> + </tabs> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index e4a5fcc67c4..79df225c432 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import tooltip from '../../../vue_shared/directives/tooltip'; import Icon from '../../../vue_shared/components/icon.vue'; import { rightSidebarViews } from '../../constants'; @@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; import MergeRequestInfo from '../merge_requests/info.vue'; import ResizablePanel from '../resizable_panel.vue'; +import Clientside from '../preview/clientside.vue'; export default { directives: { @@ -18,15 +19,20 @@ export default { JobsDetail, ResizablePanel, MergeRequestInfo, + Clientside, }, computed: { - ...mapState(['rightPane', 'currentMergeRequestId']), + ...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']), + ...mapGetters(['packageJson']), pipelinesActive() { return ( this.rightPane === rightSidebarViews.pipelines || this.rightPane === rightSidebarViews.jobsDetail ); }, + showLivePreview() { + return this.packageJson && this.clientsidePreviewEnabled; + }, }, methods: { ...mapActions(['setRightPane']), @@ -49,8 +55,9 @@ export default { :collapsible="false" :initial-width="350" :min-size="350" - class="multi-file-commit-panel-inner" + :class="`ide-right-sidebar-${rightPane}`" side="right" + class="multi-file-commit-panel-inner" > <component :is="rightPane" /> </resizable-panel> @@ -98,6 +105,26 @@ export default { /> </button> </li> + <li v-if="showLivePreview"> + <button + v-tooltip + :title="__('Live preview')" + :aria-label="__('Live preview')" + :class="{ + active: rightPane === $options.rightSidebarViews.clientSidePreview + }" + data-container="body" + data-placement="left" + class="ide-sidebar-link is-right" + type="button" + @click="clickTab($event, $options.rightSidebarViews.clientSidePreview)" + > + <icon + :size="16" + name="live-preview" + /> + </button> + </li> </ul> </nav> </div> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue new file mode 100644 index 00000000000..fef36eae7b1 --- /dev/null +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -0,0 +1,171 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import _ from 'underscore'; +import { Manager } from 'smooshpack'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import Navigator from './navigator.vue'; +import { packageJsonPath } from '../../constants'; +import { createPathWithExt } from '../../utils'; + +export default { + components: { + LoadingIcon, + Navigator, + }, + data() { + return { + manager: {}, + loading: false, + }; + }, + computed: { + ...mapState(['entries', 'promotionSvgPath', 'links']), + ...mapGetters(['packageJson', 'currentProject']), + normalizedEntries() { + return Object.keys(this.entries).reduce((acc, path) => { + const file = this.entries[path]; + + if (file.type === 'tree' || !(file.raw || file.content)) return acc; + + return { + ...acc, + [`/${path}`]: { + code: file.content || file.raw, + }, + }; + }, {}); + }, + mainEntry() { + if (!this.packageJson.raw) return false; + + const parsedPackage = JSON.parse(this.packageJson.raw); + + return parsedPackage.main; + }, + showPreview() { + return this.mainEntry && !this.loading; + }, + showEmptyState() { + return !this.mainEntry && !this.loading; + }, + showOpenInCodeSandbox() { + return this.currentProject && this.currentProject.visibility === 'public'; + }, + sandboxOpts() { + return { + files: { ...this.normalizedEntries }, + entry: `/${this.mainEntry}`, + showOpenInCodeSandbox: this.showOpenInCodeSandbox, + }; + }, + }, + watch: { + entries: { + deep: true, + handler: 'update', + }, + }, + mounted() { + this.loading = true; + + return this.loadFileContent(packageJsonPath) + .then(() => { + this.loading = false; + }) + .then(() => this.$nextTick()) + .then(() => this.initPreview()); + }, + beforeDestroy() { + if (!_.isEmpty(this.manager)) { + this.manager.listener(); + } + this.manager = {}; + + clearTimeout(this.timeout); + this.timeout = null; + }, + methods: { + ...mapActions(['getFileData', 'getRawFileData']), + loadFileContent(path) { + return this.getFileData({ path, makeFileActive: false }).then(() => + this.getRawFileData({ path }), + ); + }, + initPreview() { + if (!this.mainEntry) return null; + + return this.loadFileContent(this.mainEntry) + .then(() => this.$nextTick()) + .then(() => + this.initManager('#ide-preview', this.sandboxOpts, { + fileResolver: { + isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]), + readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content), + }, + }), + ); + }, + update() { + if (this.timeout) return; + + this.timeout = setTimeout(() => { + if (_.isEmpty(this.manager)) { + this.initPreview(); + + return; + } + + this.manager.updatePreview(this.sandboxOpts); + + clearTimeout(this.timeout); + this.timeout = null; + }, 500); + }, + initManager(el, opts, resolver) { + this.manager = new Manager(el, opts, resolver); + }, + }, +}; +</script> + +<template> + <div class="preview h-100 w-100 d-flex flex-column"> + <template v-if="showPreview"> + <navigator + :manager="manager" + /> + <div id="ide-preview"></div> + </template> + <div + v-else-if="showEmptyState" + v-once + class="d-flex h-100 flex-column align-items-center justify-content-center svg-content" + > + <img + :src="promotionSvgPath" + :alt="s__('IDE|Live Preview')" + width="130" + height="100" + /> + <h3> + {{ s__('IDE|Live Preview') }} + </h3> + <p class="text-center"> + {{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }} + </p> + <a + :href="links.webIDEHelpPagePath" + class="btn btn-primary" + target="_blank" + rel="noopener noreferrer" + > + {{ s__('IDE|Get started with Live Preview') }} + </a> + </div> + <loading-icon + v-else + size="2" + class="align-self-center mt-auto mb-auto" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue new file mode 100644 index 00000000000..4bf346946b6 --- /dev/null +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -0,0 +1,147 @@ +<script> +import { listen } from 'codesandbox-api'; +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; + +export default { + components: { + Icon, + LoadingIcon, + }, + props: { + manager: { + type: Object, + required: true, + }, + }, + data() { + return { + currentBrowsingIndex: null, + navigationStack: [], + forwardNavigationStack: [], + path: '', + loading: true, + }; + }, + computed: { + backButtonDisabled() { + return this.navigationStack.length <= 1; + }, + forwardButtonDisabled() { + return !this.forwardNavigationStack.length; + }, + }, + mounted() { + this.listener = listen(e => { + switch (e.type) { + case 'urlchange': + this.onUrlChange(e); + break; + case 'done': + this.loading = false; + break; + default: + break; + } + }); + }, + beforeDestroy() { + this.listener(); + }, + methods: { + onUrlChange(e) { + const lastPath = this.path; + + this.path = e.url.replace(this.manager.bundlerURL, '') || '/'; + + if (lastPath !== this.path) { + this.currentBrowsingIndex = + this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1; + this.navigationStack.push(this.path); + } + }, + back() { + const lastPath = this.path; + + this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]); + + this.forwardNavigationStack.push(lastPath); + + if (this.currentBrowsingIndex === 1) { + this.currentBrowsingIndex = null; + this.navigationStack = []; + } + }, + forward() { + this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]); + }, + refresh() { + this.visitPath(this.path); + }, + visitPath(path) { + this.manager.iframe.src = `${this.manager.bundlerURL}${path}`; + }, + }, +}; +</script> + +<template> + <header class="ide-preview-header d-flex align-items-center"> + <button + :aria-label="s__('IDE|Back')" + :disabled="backButtonDisabled" + :class="{ + 'disabled-content': backButtonDisabled + }" + type="button" + class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" + @click="back" + > + <icon + :size="24" + name="chevron-left" + class="m-auto" + /> + </button> + <button + :aria-label="s__('IDE|Back')" + :disabled="forwardButtonDisabled" + :class="{ + 'disabled-content': forwardButtonDisabled + }" + type="button" + class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" + @click="forward" + > + <icon + :size="24" + name="chevron-right" + class="m-auto" + /> + </button> + <button + :aria-label="s__('IDE|Refresh preview')" + type="button" + class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" + @click="refresh" + > + <icon + :size="18" + name="retry" + class="m-auto" + /> + </button> + <div class="position-relative w-100 prepend-left-4"> + <input + :value="path || '/'" + type="text" + class="ide-navigator-location form-control bg-white" + readonly + /> + <loading-icon + v-if="loading" + class="position-absolute ide-preview-loading-icon" + /> + </div> + </header> +</template> diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue new file mode 100644 index 00000000000..a7a12f6785d --- /dev/null +++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue @@ -0,0 +1,121 @@ +<script> +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + props: { + placeholder: { + type: String, + required: false, + default: __('Search'), + }, + tokens: { + type: Array, + required: false, + default: () => [], + }, + value: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + backspaceCount: 0, + }; + }, + computed: { + placeholderText() { + return this.tokens.length + ? '' + : this.placeholder; + }, + }, + watch: { + tokens() { + this.$refs.input.focus(); + }, + }, + methods: { + onFocus() { + this.$emit('focus'); + }, + onBlur() { + this.$emit('blur'); + }, + onInput(evt) { + this.$emit('input', evt.target.value); + }, + onBackspace() { + if (!this.value && this.tokens.length) { + this.backspaceCount += 1; + } else { + this.backspaceCount = 0; + return; + } + + if (this.backspaceCount > 1) { + this.removeToken(this.tokens[this.tokens.length - 1]); + this.backspaceCount = 0; + } + }, + removeToken(token) { + this.$emit('removeToken', token); + }, + }, +}; +</script> + +<template> + <div class="filtered-search-wrapper"> + <div class="filtered-search-box"> + <div class="tokens-container list-unstyled"> + <div + v-for="token in tokens" + :key="token.label" + class="filtered-search-token" + > + <button + class="selectable btn-blank" + type="button" + @click.stop="removeToken(token)" + @keyup.delete="removeToken(token)" + > + <div + class="value-container rounded" + > + <div + class="value" + >{{ token.label }}</div> + <div + class="remove-token inverted" + > + <icon + :size="10" + name="close" + /> + </div> + </div> + </button> + </div> + <div class="input-token"> + <input + ref="input" + :placeholder="placeholderText" + :value="value" + type="search" + class="form-control filtered-search" + @input="onInput" + @focus="onFocus" + @blur="onBlur" + @keyup.delete="onBackspace" + /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index d3ac57471c9..8caa5b86a9b 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -32,6 +32,7 @@ export const rightSidebarViews = { pipelines: 'pipelines-list', jobsDetail: 'jobs-detail', mergeRequestInfo: 'merge-request-info', + clientSidePreview: 'clientside', }; export const stageKeys = { @@ -58,3 +59,5 @@ export const modalTypes = { rename: 'rename', tree: 'tree', }; + +export const packageJsonPath = 'package.json'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 2d74192e6b3..79e38ae911e 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate'; import ide from './components/ide.vue'; import store from './stores'; import router from './ide_router'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; Vue.use(Translate); @@ -23,13 +24,18 @@ export function initIde(el) { noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, committedStateSvgPath: el.dataset.committedStateSvgPath, pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath, + promotionSvgPath: el.dataset.promotionSvgPath, }); this.setLinks({ ciHelpPagePath: el.dataset.ciHelpPagePath, + webIDEHelpPagePath: el.dataset.webIdeHelpPagePath, + }); + this.setInitialData({ + clientsidePreviewEnabled: convertPermissionToBoolean(el.dataset.clientsidePreviewEnabled), }); }, methods: { - ...mapActions(['setEmptyStateSvgs', 'setLinks']), + ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']), }, render(createElement) { return createElement('ide'); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 79cdb494e5a..709748fb530 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,5 +1,5 @@ import { getChangesCountForFiles, filePathMatches } from './utils'; -import { activityBarViews } from '../constants'; +import { activityBarViews, packageJsonPath } from '../constants'; export const activeFile = state => state.openFiles.find(file => file.active) || null; @@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => { export const currentBranch = (state, getters) => getters.currentProject && getters.currentProject.branches[state.currentBranchId]; +export const packageJson = state => state.entries[packageJsonPath]; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index f8ce8a67ec0..a601dc8f5a0 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -7,6 +7,7 @@ import mutations from './mutations'; import commitModule from './modules/commit'; import pipelines from './modules/pipelines'; import mergeRequests from './modules/merge_requests'; +import branches from './modules/branches'; Vue.use(Vuex); @@ -20,6 +21,7 @@ export const createStore = () => commit: commitModule, pipelines, mergeRequests, + branches, }, }); diff --git a/app/assets/javascripts/ide/stores/modules/branches/actions.js b/app/assets/javascripts/ide/stores/modules/branches/actions.js new file mode 100644 index 00000000000..74aa98ef9f9 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/branches/actions.js @@ -0,0 +1,39 @@ +import { __ } from '~/locale'; +import Api from '~/api'; +import * as types from './mutation_types'; + +export const requestBranches = ({ commit }) => commit(types.REQUEST_BRANCHES); +export const receiveBranchesError = ({ commit, dispatch }, { search }) => { + dispatch( + 'setErrorMessage', + { + text: __('Error loading branches.'), + action: payload => + dispatch('fetchBranches', payload).then(() => + dispatch('setErrorMessage', null, { root: true }), + ), + actionText: __('Please try again'), + actionPayload: { search }, + }, + { root: true }, + ); + commit(types.RECEIVE_BRANCHES_ERROR); +}; +export const receiveBranchesSuccess = ({ commit }, data) => + commit(types.RECEIVE_BRANCHES_SUCCESS, data); + +export const fetchBranches = ({ dispatch, rootGetters }, { search = '' }) => { + dispatch('requestBranches'); + dispatch('resetBranches'); + + return Api.branches(rootGetters.currentProject.id, search, { sort: 'updated_desc' }) + .then(({ data }) => dispatch('receiveBranchesSuccess', data)) + .catch(() => dispatch('receiveBranchesError', { search })); +}; + +export const resetBranches = ({ commit }) => commit(types.RESET_BRANCHES); + +export const openBranch = ({ rootState, dispatch }, id) => + dispatch('goToRoute', `/project/${rootState.currentProjectId}/edit/${id}`, { root: true }); + +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/branches/index.js b/app/assets/javascripts/ide/stores/modules/branches/index.js new file mode 100644 index 00000000000..04e7e0f08f1 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/branches/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default { + namespaced: true, + state: state(), + actions, + mutations, +}; diff --git a/app/assets/javascripts/ide/stores/modules/branches/mutation_types.js b/app/assets/javascripts/ide/stores/modules/branches/mutation_types.js new file mode 100644 index 00000000000..2272f7b9531 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/branches/mutation_types.js @@ -0,0 +1,5 @@ +export const REQUEST_BRANCHES = 'REQUEST_BRANCHES'; +export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR'; +export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS'; + +export const RESET_BRANCHES = 'RESET_BRANCHES'; diff --git a/app/assets/javascripts/ide/stores/modules/branches/mutations.js b/app/assets/javascripts/ide/stores/modules/branches/mutations.js new file mode 100644 index 00000000000..081ec2d4c28 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/branches/mutations.js @@ -0,0 +1,21 @@ +/* eslint-disable no-param-reassign */ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_BRANCHES](state) { + state.isLoading = true; + }, + [types.RECEIVE_BRANCHES_ERROR](state) { + state.isLoading = false; + }, + [types.RECEIVE_BRANCHES_SUCCESS](state, data) { + state.isLoading = false; + state.branches = data.map(branch => ({ + name: branch.name, + committedDate: branch.commit.committed_date, + })); + }, + [types.RESET_BRANCHES](state) { + state.branches = []; + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/branches/state.js b/app/assets/javascripts/ide/stores/modules/branches/state.js new file mode 100644 index 00000000000..89bf220c45f --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/branches/state.js @@ -0,0 +1,4 @@ +export default () => ({ + isLoading: false, + branches: [], +}); diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js index 6ef938b0ae2..baa2497ec5b 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -1,12 +1,10 @@ import { __ } from '../../../../locale'; import Api from '../../../../api'; -import router from '../../../ide_router'; import { scopes } from './constants'; import * as types from './mutation_types'; -import * as rootTypes from '../../mutation_types'; -export const requestMergeRequests = ({ commit }, type) => - commit(types.REQUEST_MERGE_REQUESTS, type); +export const requestMergeRequests = ({ commit }) => + commit(types.REQUEST_MERGE_REQUESTS); export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => { dispatch( 'setErrorMessage', @@ -21,39 +19,22 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search } }, { root: true }, ); - commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type); + commit(types.RECEIVE_MERGE_REQUESTS_ERROR); }; -export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) => - commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { type, data }); +export const receiveMergeRequestsSuccess = ({ commit }, data) => + commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data); export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => { - const scope = scopes[type]; - dispatch('requestMergeRequests', type); - dispatch('resetMergeRequests', type); + dispatch('requestMergeRequests'); + dispatch('resetMergeRequests'); + + const scope = type ? scopes[type] : 'all'; return Api.mergeRequests({ scope, state, search }) - .then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data })) + .then(({ data }) => dispatch('receiveMergeRequestsSuccess', data)) .catch(() => dispatch('receiveMergeRequestsError', { type, search })); }; -export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type); - -export const openMergeRequest = ({ commit, dispatch }, { projectPath, id }) => { - commit(rootTypes.CLEAR_PROJECTS, null, { root: true }); - commit(rootTypes.SET_CURRENT_MERGE_REQUEST, `${id}`, { root: true }); - commit(rootTypes.RESET_OPEN_FILES, null, { root: true }); - dispatch('setCurrentBranchId', '', { root: true }); - dispatch('pipelines/stopPipelinePolling', null, { root: true }) - .then(() => { - dispatch('pipelines/resetLatestPipeline', null, { root: true }); - dispatch('pipelines/clearEtagPoll', null, { root: true }); - }) - .catch(e => { - throw e; - }); - dispatch('setRightPane', null, { root: true }); - - router.push(`/project/${projectPath}/merge_requests/${id}`); -}; +export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS); export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js b/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js deleted file mode 100644 index 8e2b234be8d..00000000000 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js +++ /dev/null @@ -1,4 +0,0 @@ -export const getData = state => type => state[type]; - -export const assignedData = state => state.assigned; -export const createdData = state => state.created; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/index.js b/app/assets/javascripts/ide/stores/modules/merge_requests/index.js index 2e6dfb420f4..04e7e0f08f1 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/index.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/index.js @@ -1,6 +1,5 @@ import state from './state'; import * as actions from './actions'; -import * as getters from './getters'; import mutations from './mutations'; export default { @@ -8,5 +7,4 @@ export default { state: state(), actions, mutations, - getters, }; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js index 971da0806bd..98102a68e08 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js @@ -2,15 +2,15 @@ import * as types from './mutation_types'; export default { - [types.REQUEST_MERGE_REQUESTS](state, type) { - state[type].isLoading = true; + [types.REQUEST_MERGE_REQUESTS](state) { + state.isLoading = true; }, - [types.RECEIVE_MERGE_REQUESTS_ERROR](state, type) { - state[type].isLoading = false; + [types.RECEIVE_MERGE_REQUESTS_ERROR](state) { + state.isLoading = false; }, - [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { type, data }) { - state[type].isLoading = false; - state[type].mergeRequests = data.map(mergeRequest => ({ + [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) { + state.isLoading = false; + state.mergeRequests = data.map(mergeRequest => ({ id: mergeRequest.id, iid: mergeRequest.iid, title: mergeRequest.title, @@ -20,7 +20,7 @@ export default { .replace(`/merge_requests/${mergeRequest.iid}`, ''), })); }, - [types.RESET_MERGE_REQUESTS](state, type) { - state[type].mergeRequests = []; + [types.RESET_MERGE_REQUESTS](state) { + state.mergeRequests = []; }, }; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js index 57eb6b04283..4748ccfa2e6 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js @@ -1,13 +1,7 @@ import { states } from './constants'; export default () => ({ - created: { - isLoading: false, - mergeRequests: [], - }, - assigned: { - isLoading: false, - mergeRequests: [], - }, + isLoading: false, + mergeRequests: [], state: states.opened, }); diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index d0bf847dbde..1eda5768709 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -115,13 +115,20 @@ export default { }, [types.SET_EMPTY_STATE_SVGS]( state, - { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath }, + { + emptyStateSvgPath, + noChangesStateSvgPath, + committedStateSvgPath, + pipelinesEmptyStateSvgPath, + promotionSvgPath, + }, ) { Object.assign(state, { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath, + promotionSvgPath, }); }, [types.TOGGLE_FILE_FINDER](state, fileFindVisible) { diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index c75add39bcd..a937fb157f8 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -44,7 +44,7 @@ export default { rawPath: data.raw_path, binary: data.binary, renderError: data.render_error, - raw: null, + raw: (state.entries[file.path] && state.entries[file.path].raw) || null, baseRaw: null, html: data.html, size: data.size, diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 2371b201f8c..46b52fa00fc 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -31,4 +31,5 @@ export default () => ({ path: '', entry: {}, }, + clientsidePreviewEnabled: false, }); diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 92b15cf232d..d895eca7af0 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,6 +1,5 @@ import { commitItemIconMap } from './constants'; -// eslint-disable-next-line import/prefer-default-export export const getCommitIconMap = file => { if (file.deleted) { return commitItemIconMap.deleted; @@ -10,3 +9,9 @@ export const getCommitIconMap = file => { return commitItemIconMap.modified; }; + +export const createPathWithExt = p => { + const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : ''; + + return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`; +}; diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index f9ff0722c01..0035d809062 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -36,6 +36,8 @@ class ImporterStatus { const $targetField = $tr.find('.import-target'); const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); const id = $tr.attr('id').replace('repo_', ''); + const repoData = $tr.data(); + let targetNamespace; let newName; if ($namespaceInput.length > 0) { @@ -45,12 +47,20 @@ class ImporterStatus { } $btn.disable().addClass('is-loading'); - return axios.post(this.importUrl, { + this.id = id; + + let attributes = { repo_id: id, target_namespace: targetNamespace, new_name: newName, ci_cd_only: this.ciCdOnly, - }) + }; + + if (repoData) { + attributes = Object.assign(repoData, attributes); + } + + return axios.post(this.importUrl, attributes) .then(({ data }) => { const job = $(`tr#repo_${id}`); job.attr('id', `project_${data.id}`); @@ -70,6 +80,9 @@ class ImporterStatus { .catch((error) => { let details = error; + const $statusField = $(`#repo_${this.id} .job-status`); + $statusField.text(__('Failed')); + if (error.response && error.response.data && error.response.data.errors) { details = error.response.data.errors; } diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 37a45d1d1a2..cb851ff6745 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -39,7 +39,7 @@ export default class LabelsSelect { showNo = $dropdown.data('showNo'); showAny = $dropdown.data('showAny'); showMenuAbove = $dropdown.data('showMenuAbove'); - defaultLabel = $dropdown.data('defaultLabel'); + defaultLabel = $dropdown.data('defaultLabel') || 'Label'; abilityName = $dropdown.data('abilityName'); $selectbox = $dropdown.closest('.selectbox'); $block = $selectbox.closest('.block'); @@ -244,21 +244,21 @@ export default class LabelsSelect { var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); var isSelected = el !== null ? el.hasClass('is-active') : false; - var { title } = selected; + var title = selected ? selected.title : null; var selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { $dropdownParent.find('.dropdown-input-clear').trigger('click'); } - if (selected.id === 0) { + if (selected && selected.id === 0) { this.selected = []; return 'No Label'; } else if (isSelected) { this.selected.push(title); } - else { + else if (!isSelected && title) { var index = this.selected.indexOf(title); this.selected.splice(index, 1); } @@ -409,6 +409,14 @@ export default class LabelsSelect { } } }, + opened: function(e) { + if ($dropdown.hasClass('js-issue-board-sidebar')) { + const previousSelection = $dropdown.attr('data-selected'); + this.selected = previousSelection ? previousSelection.split(',') : []; + $dropdown.data('glDropdown').updateLabel(); + } + }, + preserveContext: true, }); // Set dropdown data diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 9482d131344..bd2212edec7 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,6 +1,7 @@ import _ from 'underscore'; -export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; +export const placeholderImage = + 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; const SCROLL_THRESHOLD = 300; export default class LazyLoader { @@ -18,11 +19,17 @@ export default class LazyLoader { scrollContainer.addEventListener('load', () => this.loadCheck()); } searchLazyImages() { - this.lazyImages = [].slice.call(document.querySelectorAll('.lazy')); + const that = this; + requestIdleCallback( + () => { + that.lazyImages = [].slice.call(document.querySelectorAll('.lazy')); - if (this.lazyImages.length) { - this.checkElementsInView(); - } + if (that.lazyImages.length) { + that.checkElementsInView(); + } + }, + { timeout: 500 }, + ); } startContentObserver() { const contentNode = document.querySelector(this.observerNode) || document.querySelector('body'); @@ -48,14 +55,16 @@ export default class LazyLoader { const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD; // Loading Images which are in the current viewport or close to them - this.lazyImages = this.lazyImages.filter((selectedImage) => { + this.lazyImages = this.lazyImages.filter(selectedImage => { if (selectedImage.getAttribute('data-src')) { const imgBoundRect = selectedImage.getBoundingClientRect(); const imgTop = scrollTop + imgBoundRect.top; const imgBound = imgTop + imgBoundRect.height; if (scrollTop < imgBound && visHeight > imgTop) { - LazyLoader.loadImage(selectedImage); + requestAnimationFrame(() => { + LazyLoader.loadImage(selectedImage); + }); return false; } @@ -66,7 +75,18 @@ export default class LazyLoader { } static loadImage(img) { if (img.getAttribute('data-src')) { - img.setAttribute('src', img.getAttribute('data-src')); + let imgUrl = img.getAttribute('data-src'); + // Only adding width + height for avatars for now + if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) { + let targetWidth = null; + if (img.getAttribute('width')) { + targetWidth = img.getAttribute('width'); + } else { + targetWidth = img.width; + } + if (targetWidth) imgUrl += `?width=${targetWidth}`; + } + img.setAttribute('src', imgUrl); img.removeAttribute('data-src'); img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 17a6d5bcd2a..6afaefc56f8 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -147,6 +147,7 @@ export default { } this.showEmptyState = false; }) + .then(this.resize) .catch(() => { this.state = 'unableToConnect'; }); diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 6385b75e557..ad6e7cf501d 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -5,19 +5,20 @@ import resolvedSvg from 'icons/_icon_status_success_solid.svg'; import mrIssueSvg from 'icons/_icon_mr_issue.svg'; import nextDiscussionSvg from 'icons/_next_discussion.svg'; import { pluralize } from '../../lib/utils/text_utility'; -import { scrollToElement } from '../../lib/utils/common_utils'; +import discussionNavigation from '../mixins/discussion_navigation'; import tooltip from '../../vue_shared/directives/tooltip'; export default { directives: { tooltip, }, + mixins: [discussionNavigation], computed: { ...mapGetters([ 'getUserData', 'getNoteableData', 'discussionCount', - 'unresolvedDiscussions', + 'firstUnresolvedDiscussionId', 'resolvedDiscussionCount', ]), isLoggedIn() { @@ -35,11 +36,6 @@ export default { resolveAllDiscussionsIssuePath() { return this.getNoteableData.create_issue_to_resolve_discussions_path; }, - firstUnresolvedDiscussionId() { - const item = this.unresolvedDiscussions[0] || {}; - - return item.id; - }, }, created() { this.resolveSvg = resolveSvg; @@ -50,22 +46,10 @@ export default { methods: { ...mapActions(['expandDiscussion']), jumpToFirstUnresolvedDiscussion() { - const discussionId = this.firstUnresolvedDiscussionId; - if (!discussionId) { - return; - } - - const el = document.querySelector(`[data-discussion-id="${discussionId}"]`); - const activeTab = window.mrTabs.currentAction; - - if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.activateTab('show'); - } + const diffTab = window.mrTabs.currentAction === 'diffs'; + const discussionId = this.firstUnresolvedDiscussionId(diffTab); - if (el) { - this.expandDiscussion({ discussionId }); - scrollToElement(el); - } + this.jumpToDiscussion(discussionId); }, }, }; diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 26482a02e00..abcd4422d7c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -7,7 +7,7 @@ import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; export default { - name: 'IssueNoteForm', + name: 'NoteForm', components: { issueWarning, markdownField, diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index bee635398b3..0fe1c16854a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,11 +1,11 @@ <script> -import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; import nextDiscussionsSvg from 'icons/_next_discussion.svg'; -import { convertObjectPropsToCamelCase, scrollToElement } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import systemNote from '~/vue_shared/components/notes/system_note.vue'; +import { s__ } from '~/locale'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -20,6 +20,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; +import discussionNavigation from '../mixins/discussion_navigation'; import tooltip from '../../vue_shared/directives/tooltip'; export default { @@ -39,7 +40,7 @@ export default { directives: { tooltip, }, - mixins: [autosave, noteable, resolvable], + mixins: [autosave, noteable, resolvable, discussionNavigation], props: { discussion: { type: Object, @@ -60,6 +61,11 @@ export default { required: false, default: false, }, + discussionsByDiffOrder: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -74,7 +80,12 @@ export default { 'discussionCount', 'resolvedDiscussionCount', 'allDiscussions', + 'unresolvedDiscussionsIdsByDiff', + 'unresolvedDiscussionsIdsByDate', 'unresolvedDiscussions', + 'unresolvedDiscussionsIdsOrdered', + 'nextUnresolvedDiscussionId', + 'isLastUnresolvedDiscussion', ]), transformedDiscussion() { return { @@ -125,6 +136,10 @@ export default { hasMultipleUnresolvedDiscussions() { return this.unresolvedDiscussions.length > 1; }, + showJumpToNextDiscussion() { + return this.hasMultipleUnresolvedDiscussions && + !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder); + }, shouldRenderDiffs() { const { diffDiscussion, diffFile } = this.transformedDiscussion; @@ -144,19 +159,17 @@ export default { return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; }, }, - mounted() { - if (this.isReplying) { - this.initAutoSave(this.transformedDiscussion); - } - }, - updated() { - if (this.isReplying) { - if (!this.autosave) { - this.initAutoSave(this.transformedDiscussion); + watch: { + isReplying() { + if (this.isReplying) { + this.$nextTick(() => { + // Pass an extra key to separate reply and note edit forms + this.initAutoSave(this.transformedDiscussion, ['Reply']); + }); } else { - this.setAutoSave(); + this.disposeAutoSave(); } - } + }, }, created() { this.resolveDiscussionsSvg = resolveDiscussionsSvg; @@ -194,16 +207,18 @@ export default { showReplyForm() { this.isReplying = true; }, - cancelReplyForm(shouldConfirm) { - if (shouldConfirm && this.$refs.noteForm.isDirty) { + cancelReplyForm(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); + // eslint-disable-next-line no-alert - if (!window.confirm('Are you sure you want to cancel creating this comment?')) { + if (!window.confirm(msg)) { return; } } - this.resetAutoSave(); this.isReplying = false; + this.resetAutoSave(); }, saveReply(noteText, form, callback) { const postData = { @@ -241,21 +256,10 @@ Please check your network connection and try again.`; }); }, jumpToNextDiscussion() { - const discussionIds = this.allDiscussions.map(d => d.id); - const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); - const currentIndex = discussionIds.indexOf(this.discussion.id); - const remainingAfterCurrent = discussionIds.slice(currentIndex + 1); - const nextIndex = _.findIndex(remainingAfterCurrent, id => unresolvedIds.indexOf(id) > -1); - - if (nextIndex > -1) { - const nextId = remainingAfterCurrent[nextIndex]; - const el = document.querySelector(`[data-discussion-id="${nextId}"]`); + const nextId = + this.nextUnresolvedDiscussionId(this.discussion.id, this.discussionsByDiffOrder); - if (el) { - this.expandDiscussion({ discussionId: nextId }); - scrollToElement(el); - } - } + this.jumpToDiscussion(nextId); }, }, }; @@ -397,7 +401,7 @@ Please check your network connection and try again.`; </a> </div> <div - v-if="hasMultipleUnresolvedDiscussions" + v-if="showJumpToNextDiscussion" class="btn-group" role="group"> <button @@ -420,7 +424,8 @@ Please check your network connection and try again.`; :is-editing="false" save-button-title="Comment" @handleFormUpdate="saveReply" - @cancelForm="cancelReplyForm" /> + @cancelForm="cancelReplyForm" + /> <note-signed-out-widget v-if="!canReply" /> </div> </div> diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 36cc8d5d056..4f45f912479 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -4,12 +4,18 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { - initAutoSave(noteable) { - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ + initAutoSave(noteable, extraKeys = []) { + let keys = [ 'Note', - capitalizeFirstCharacter(noteable.noteable_type), + capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType), noteable.id, - ]); + ]; + + if (extraKeys) { + keys = keys.concat(extraKeys); + } + + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys); }, resetAutoSave() { this.autosave.reset(); @@ -17,5 +23,8 @@ export default { setAutoSave() { this.autosave.save(); }, + disposeAutoSave() { + this.autosave.dispose(); + }, }, }; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js new file mode 100644 index 00000000000..f7c4deee1f8 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -0,0 +1,29 @@ +import { scrollToElement } from '~/lib/utils/common_utils'; + +export default { + methods: { + jumpToDiscussion(id) { + if (id) { + const activeTab = window.mrTabs.currentAction; + const selector = + activeTab === 'diffs' + ? `ul.notes[data-discussion-id="${id}"]` + : `div.discussion[data-discussion-id="${id}"]`; + const el = document.querySelector(selector); + + if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.activateTab('show'); + } + + if (el) { + this.expandDiscussion({ discussionId: id }); + + scrollToElement(el); + return true; + } + } + + return false; + }, + }, +}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 5c65e1c3bb5..5b3b9f8776f 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -82,6 +82,9 @@ export const allDiscussions = (state, getters) => { return Object.values(resolved).concat(unresolved); }; +export const allResolvableDiscussions = (state, getters) => + getters.allDiscussions.filter(d => !d.individual_note && d.resolvable); + export const resolvedDiscussionsById = state => { const map = {}; @@ -98,6 +101,51 @@ export const resolvedDiscussionsById = state => { return map; }; +// Gets Discussions IDs ordered by the date of their initial note +export const unresolvedDiscussionsIdsByDate = (state, getters) => + getters.allResolvableDiscussions + .filter(d => !d.resolved) + .sort((a, b) => { + const aDate = new Date(a.notes[0].created_at); + const bDate = new Date(b.notes[0].created_at); + + if (aDate < bDate) { + return -1; + } + + return aDate === bDate ? 0 : 1; + }) + .map(d => d.id); + +// Gets Discussions IDs ordered by their position in the diff +// +// Sorts the array of resolvable yet unresolved discussions by +// comparing file names first. If file names are the same, compares +// line numbers. +export const unresolvedDiscussionsIdsByDiff = (state, getters) => + getters.allResolvableDiscussions + .filter(d => !d.resolved) + .sort((a, b) => { + if (!a.diff_file || !b.diff_file) { + return 0; + } + + // Get file names comparison result + const filenameComparison = a.diff_file.file_path.localeCompare(b.diff_file.file_path); + + // Get the line numbers, to compare within the same file + const aLines = [a.position.formatter.new_line, a.position.formatter.old_line]; + const bLines = [b.position.formatter.new_line, b.position.formatter.old_line]; + + return filenameComparison < 0 || + (filenameComparison === 0 && + // .max() because one of them might be zero (if removed/added) + Math.max(aLines[0], aLines[1]) < Math.max(bLines[0], bLines[1])) + ? -1 + : 1; + }) + .map(d => d.id); + export const resolvedDiscussionCount = (state, getters) => { const resolvedMap = getters.resolvedDiscussionsById; @@ -114,5 +162,42 @@ export const discussionTabCounter = state => { return all.length; }; +// Returns the list of discussion IDs ordered according to given parameter +// @param {Boolean} diffOrder - is ordered by diff? +export const unresolvedDiscussionsIdsOrdered = (state, getters) => diffOrder => { + if (diffOrder) { + return getters.unresolvedDiscussionsIdsByDiff; + } + return getters.unresolvedDiscussionsIdsByDate; +}; + +// Checks if a given discussion is the last in the current order (diff or date) +// @param {Boolean} discussionId - id of the discussion +// @param {Boolean} diffOrder - is ordered by diff? +export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, diffOrder) => { + const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); + const lastDiscussionId = idsOrdered[idsOrdered.length - 1]; + + return lastDiscussionId === discussionId; +}; + +// Gets the ID of the discussion following the one provided, respecting order (diff or date) +// @param {Boolean} discussionId - id of the current discussion +// @param {Boolean} diffOrder - is ordered by diff? +export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { + const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); + const currentIndex = idsOrdered.indexOf(discussionId); + + return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0]; +}; + +// @param {Boolean} diffOrder - is ordered by diff? +export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { + if (diffOrder) { + return getters.unresolvedDiscussionsIdsByDiff[0]; + } + return getters.unresolvedDiscussionsIdsByDate[0]; +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index ff19b9a9c30..9aa83ce6269 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -39,6 +39,7 @@ export default class Todos { } initFilters() { + this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']); this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']); this.initFilterDropdown($('.js-type-search'), 'type'); this.initFilterDropdown($('.js-action-search'), 'action_id'); @@ -53,7 +54,16 @@ export default class Todos { filterable: searchFields ? true : false, search: { fields: searchFields }, data: $dropdown.data('data'), - clicked: () => $dropdown.closest('form.filter-form').submit(), + clicked: () => { + const $formEl = $dropdown.closest('form.filter-form'); + const mutexDropdowns = { + group_id: 'project_id', + project_id: 'group_id', + }; + + $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove(); + $formEl.submit(); + }, }); } diff --git a/app/assets/javascripts/pages/profiles/show/emoji_menu.js b/app/assets/javascripts/pages/profiles/show/emoji_menu.js new file mode 100644 index 00000000000..094837b40e0 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/show/emoji_menu.js @@ -0,0 +1,18 @@ +import { AwardsHandler } from '~/awards_handler'; + +class EmojiMenu extends AwardsHandler { + constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback) { + super(emoji); + + this.selectEmojiCallback = selectEmojiCallback; + this.toggleButtonSelector = toggleButtonSelector; + this.menuClass = menuClass; + } + + postEmoji($emojiButton, awardUrl, selectedEmoji, callback) { + this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji)); + callback(); + } +} + +export default EmojiMenu; diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js new file mode 100644 index 00000000000..949219a0837 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -0,0 +1,49 @@ +import $ from 'jquery'; +import createFlash from '~/flash'; +import GfmAutoComplete from '~/gfm_auto_complete'; +import EmojiMenu from './emoji_menu'; + +document.addEventListener('DOMContentLoaded', () => { + const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu'; + const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector); + const statusEmojiField = document.getElementById('js-status-emoji-field'); + const statusMessageField = document.getElementById('js-status-message-field'); + const findNoEmojiPlaceholder = () => document.getElementById('js-no-emoji-placeholder'); + + const removeStatusEmoji = () => { + const statusEmoji = toggleEmojiMenuButton.querySelector('gl-emoji'); + if (statusEmoji) { + statusEmoji.remove(); + } + }; + + const selectEmojiCallback = (emoji, emojiTag) => { + statusEmojiField.value = emoji; + findNoEmojiPlaceholder().classList.add('hidden'); + removeStatusEmoji(); + toggleEmojiMenuButton.innerHTML += emojiTag; + }; + + const clearEmojiButton = document.getElementById('js-clear-user-status-button'); + clearEmojiButton.addEventListener('click', () => { + statusEmojiField.value = ''; + statusMessageField.value = ''; + removeStatusEmoji(); + findNoEmojiPlaceholder().classList.remove('hidden'); + }); + + const emojiAutocomplete = new GfmAutoComplete(); + emojiAutocomplete.setup($(statusMessageField), { emojis: true }); + + import(/* webpackChunkName: 'emoji' */ '~/emoji') + .then(Emoji => { + const emojiMenu = new EmojiMenu( + Emoji, + toggleEmojiMenuButtonSelector, + 'js-status-emoji-menu', + selectEmojiCallback, + ); + emojiMenu.bindEvents(); + }) + .catch(() => createFlash('Failed to load emoji list!')); +}); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 1faa59fb45b..8f5ac3d8082 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -23,17 +23,12 @@ document.addEventListener('DOMContentLoaded', () => { saveEndpoint: variableListEl.dataset.saveEndpoint, }); - // hide extra auto devops settings based on data-attributes - const autoDevOpsSettings = document.querySelector('.js-auto-devops-settings'); + // hide extra auto devops settings based checkbox state const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); - - autoDevOpsSettings.addEventListener('click', event => { + const instanceDefaultBadge = document.querySelector('.js-instance-default-badge'); + document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => { const { target } = event; - if (target.classList.contains('js-toggle-extra-settings')) { - autoDevOpsExtraSettings.classList.toggle( - 'hidden', - !!(target.dataset && target.dataset.hideExtraSettings), - ); - } + if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; + autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); }); }); diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index d2dc0c4570e..b76f2f76449 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -17,7 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { new ShortcutsNavigation(); // eslint-disable-line no-new new NotificationsForm(); // eslint-disable-line no-new new UserCallout({ // eslint-disable-line no-new - setCalloutPerProject: true, + setCalloutPerProject: false, className: 'js-autodevops-banner', }); diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 0fc84b4552a..140475b4dfa 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -69,7 +69,7 @@ return ( report.existing_failures.length > 0 || report.new_failures.length > 0 || - report.resolved_failures > 0 + report.resolved_failures.length > 0 ); }, }, @@ -82,7 +82,7 @@ :loading-text="groupedSummaryText" :error-text="groupedSummaryText" :has-issues="reports.length > 0" - class="mr-widget-border-top grouped-security-reports" + class="mr-widget-border-top grouped-security-reports mr-report" > <div slot="body" diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index b2133714858..acc5c6d85e2 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -36,11 +36,11 @@ :key="index" class="row prepend-top-10 append-bottom-10" > - <strong class="col-sm-2 text-right"> + <strong class="col-sm-3 text-right"> {{ field.text }}: </strong> - <div class="col-sm-10 text-secondary"> + <div class="col-sm-9 text-secondary"> <code-block v-if="field.type === $options.fieldTypes.codeBock" :code="field.value" diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index e806d120b51..1983a8c9e56 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -9,6 +9,8 @@ export default { state.isLoading = true; }, [types.RECEIVE_REPORTS_SUCCESS](state, response) { + // Make sure to clean previous state in case it was an error + state.hasError = false; state.isLoading = false; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 72a2c7ca101..aec09b8bc0a 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,9 +1,18 @@ -/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, quotes, class-methods-use-this, no-lonely-if, no-else-return, vars-on-top, max-len */ +/* eslint-disable no-return-assign, one-var, no-var, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top, max-len */ import $ from 'jquery'; +import { escape, throttle } from 'underscore'; +import { s__, sprintf } from '~/locale'; +import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; import axios from './lib/utils/axios_utils'; import DropdownUtils from './filtered_search/dropdown_utils'; -import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils'; +import { + isInGroupsPage, + isInProjectPage, + getGroupSlug, + getProjectSlug, + spriteIcon, +} from './lib/utils/common_utils'; /** * Search input in top navigation bar. @@ -52,6 +61,7 @@ function setSearchOptions() { if ($dashboardOptionsDataEl.length) { gl.dashboardOptions = { + name: s__('SearchAutocomplete|All GitLab'), issuesPath: $dashboardOptionsDataEl.data('issuesPath'), mrPath: $dashboardOptionsDataEl.data('mrPath'), }; @@ -69,8 +79,8 @@ export default class SearchAutocomplete { this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || ''); this.dropdown = this.wrap.find('.dropdown'); this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); + this.dropdownMenu = this.dropdown.find('.dropdown-menu'); this.dropdownContent = this.dropdown.find('.dropdown-content'); - this.locationBadgeEl = this.getElement('.location-badge'); this.scopeInputEl = this.getElement('#scope'); this.searchInput = this.getElement('.search-input'); this.projectInputEl = this.getElement('#search_project_id'); @@ -78,6 +88,7 @@ export default class SearchAutocomplete { this.searchCodeInputEl = this.getElement('#search_code'); this.repositoryInputEl = this.getElement('#repository_ref'); this.clearInput = this.getElement('.js-clear-input'); + this.scrollFadeInitialized = false; this.saveOriginalState(); // Only when user is logged in @@ -98,17 +109,18 @@ export default class SearchAutocomplete { this.onSearchInputFocus = this.onSearchInputFocus.bind(this); this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this); + this.setScrollFade = this.setScrollFade.bind(this); } getElement(selector) { return this.wrap.find(selector); } saveOriginalState() { - return this.originalState = this.serializeState(); + return (this.originalState = this.serializeState()); } saveTextLength() { - return this.lastTextLength = this.searchInput.val().length; + return (this.lastTextLength = this.searchInput.val().length); } createAutocomplete() { @@ -117,6 +129,7 @@ export default class SearchAutocomplete { filterable: true, filterRemote: true, highlight: true, + icon: true, enterCallback: false, filterInput: 'input#search', search: { @@ -154,60 +167,87 @@ export default class SearchAutocomplete { this.loadingSuggestions = true; - return axios.get(this.autocompletePath, { - params: { - project_id: this.projectId, - project_ref: this.projectRef, - term: term, - }, - }).then((response) => { - // Hide dropdown menu if no suggestions returns - if (!response.data.length) { - this.disableAutocomplete(); - return; - } + return axios + .get(this.autocompletePath, { + params: { + project_id: this.projectId, + project_ref: this.projectRef, + term: term, + }, + }) + .then(response => { + // Hide dropdown menu if no suggestions returns + if (!response.data.length) { + this.disableAutocomplete(); + return; + } - const data = []; - // List results - let firstCategory = true; - let lastCategory; - for (let i = 0, len = response.data.length; i < len; i += 1) { - const suggestion = response.data[i]; - // Add group header before list each group - if (lastCategory !== suggestion.category) { - if (!firstCategory) { - data.push('separator'); - } - if (firstCategory) { - firstCategory = false; + const data = []; + // List results + let firstCategory = true; + let lastCategory; + for (let i = 0, len = response.data.length; i < len; i += 1) { + const suggestion = response.data[i]; + // Add group header before list each group + if (lastCategory !== suggestion.category) { + if (!firstCategory) { + data.push('separator'); + } + if (firstCategory) { + firstCategory = false; + } + data.push({ + header: suggestion.category, + }); + lastCategory = suggestion.category; } data.push({ - header: suggestion.category, + id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, + icon: this.getAvatar(suggestion), + category: suggestion.category, + text: suggestion.label, + url: suggestion.url, }); - lastCategory = suggestion.category; } - data.push({ - id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, - category: suggestion.category, - text: suggestion.label, - url: suggestion.url, - }); - } - // Add option to proceed with the search - if (data.length) { - data.push('separator'); - data.push({ - text: `Result name contains "${term}"`, - url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`, - }); - } + // Add option to proceed with the search + if (data.length) { + const icon = spriteIcon('search', 's16 inline-search-icon'); + let template; - callback(data); + if (this.projectInputEl.val()) { + template = s__('SearchAutocomplete|in this project'); + } + if (this.groupInputEl.val()) { + template = s__('SearchAutocomplete|in this group'); + } - this.loadingSuggestions = false; - }).catch(() => { - this.loadingSuggestions = false; - }); + data.unshift('separator'); + data.unshift({ + icon, + text: term, + template: s__('SearchAutocomplete|in all GitLab'), + url: `/search?search=${term}`, + }); + + if (template) { + data.unshift({ + icon, + text: term, + template, + url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`, + }); + } + } + + callback(data); + + this.loadingSuggestions = false; + this.highlightFirstRow(); + this.setScrollFade(); + }) + .catch(() => { + this.loadingSuggestions = false; + }); } getCategoryContents() { @@ -236,21 +276,21 @@ export default class SearchAutocomplete { const issueItems = [ { - text: 'Issues assigned to me', + text: s__('SearchAutocomplete|Issues assigned to me'), url: `${issuesPath}/?assignee_id=${userId}`, }, { - text: "Issues I've created", + text: s__("SearchAutocomplete|Issues I've created"), url: `${issuesPath}/?author_id=${userId}`, }, ]; const mergeRequestItems = [ { - text: 'Merge requests assigned to me', + text: s__('SearchAutocomplete|Merge requests assigned to me'), url: `${mrPath}/?assignee_id=${userId}`, }, { - text: "Merge requests I've created", + text: s__("SearchAutocomplete|Merge requests I've created"), url: `${mrPath}/?author_id=${userId}`, }, ]; @@ -259,7 +299,7 @@ export default class SearchAutocomplete { if (issuesDisabled) { items = baseItems.concat(mergeRequestItems); } else { - items = baseItems.concat(...issueItems, 'separator', ...mergeRequestItems); + items = baseItems.concat(...issueItems, ...mergeRequestItems); } return items; } @@ -272,8 +312,6 @@ export default class SearchAutocomplete { search_code: this.searchCodeInputEl.val(), repository_ref: this.repositoryInputEl.val(), scope: this.scopeInputEl.val(), - // Location badge - _location: this.locationBadgeEl.text(), }; } @@ -283,10 +321,12 @@ export default class SearchAutocomplete { this.searchInput.on('focus', this.onSearchInputFocus); this.searchInput.on('blur', this.onSearchInputBlur); this.clearInput.on('click', this.onClearInputClick); - this.locationBadgeEl.on('click', () => this.searchInput.focus()); + this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250)); } enableAutocomplete() { + this.setScrollFade(); + // No need to enable anything if user is not logged in if (!gon.current_user_id) { return; @@ -308,10 +348,6 @@ export default class SearchAutocomplete { onSearchInputKeyUp(e) { switch (e.keyCode) { case KEYCODE.BACKSPACE: - // when trying to remove the location badge - if (this.lastTextLength === 0 && this.badgePresent()) { - this.removeLocationBadge(); - } // When removing the last character and no badge is present if (this.lastTextLength === 1) { this.disableAutocomplete(); @@ -372,37 +408,13 @@ export default class SearchAutocomplete { } } - addLocationBadge(item) { - var badgeText, category, value; - category = item.category != null ? item.category + ": " : ''; - value = item.value != null ? item.value : ''; - badgeText = "" + category + value; - this.locationBadgeEl.text(badgeText).show(); - return this.wrap.addClass('has-location-badge'); - } - - hasLocationBadge() { - return this.wrap.is('.has-location-badge'); - } - restoreOriginalState() { var i, input, inputs, len; inputs = Object.keys(this.originalState); for (i = 0, len = inputs.length; i < len; i += 1) { input = inputs[i]; - this.getElement("#" + input).val(this.originalState[input]); + this.getElement('#' + input).val(this.originalState[input]); } - if (this.originalState._location === '') { - return this.locationBadgeEl.hide(); - } else { - return this.addLocationBadge({ - value: this.originalState._location, - }); - } - } - - badgePresent() { - return this.locationBadgeEl.length; } resetSearchState() { @@ -411,22 +423,11 @@ export default class SearchAutocomplete { results = []; for (i = 0, len = inputs.length; i < len; i += 1) { input = inputs[i]; - // _location isnt a input - if (input === '_location') { - break; - } - results.push(this.getElement("#" + input).val('')); + results.push(this.getElement('#' + input).val('')); } return results; } - removeLocationBadge() { - this.locationBadgeEl.hide(); - this.resetSearchState(); - this.wrap.removeClass('has-location-badge'); - return this.disableAutocomplete(); - } - disableAutocomplete() { if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) { this.searchInput.addClass('disabled'); @@ -444,23 +445,57 @@ export default class SearchAutocomplete { onClick(item, $el, e) { if (window.location.pathname.indexOf(item.url) !== -1) { if (!e.metaKey) e.preventDefault(); - if (!this.badgePresent) { - if (item.category === 'Projects') { - this.projectInputEl.val(item.id); - this.addLocationBadge({ - value: 'This project', - }); - } - if (item.category === 'Groups') { - this.groupInputEl.val(item.id); - this.addLocationBadge({ - value: 'This group', - }); - } + if (item.category === 'Projects') { + this.projectInputEl.val(item.id); + } + if (item.category === 'Groups') { + this.groupInputEl.val(item.id); } $el.removeClass('is-active'); this.disableAutocomplete(); return this.searchInput.val('').focus(); } } + + highlightFirstRow() { + this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0); + } + + getAvatar(item) { + if (!Object.hasOwnProperty.call(item, 'avatar_url')) { + return false; + } + + const { label, id } = item; + const avatarUrl = item.avatar_url; + const avatar = avatarUrl + ? `<img class="search-item-avatar" src="${avatarUrl}" />` + : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( + escape(label), + )}</div>`; + + return avatar; + } + + isScrolledUp() { + const el = this.dropdownContent[0]; + const currentPosition = this.contentClientHeight + el.scrollTop; + + return currentPosition < this.maxPosition; + } + + initScrollFade() { + const el = this.dropdownContent[0]; + this.scrollFadeInitialized = true; + + this.contentClientHeight = el.clientHeight; + this.maxPosition = el.scrollHeight; + this.dropdownMenu.addClass('dropdown-content-faded-mask'); + } + + setScrollFade() { + this.initScrollFade(); + + this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp()); + } } diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue new file mode 100644 index 00000000000..ffaed9c7193 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -0,0 +1,98 @@ +<script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; + +const MARK_TEXT = __('Mark todo as done'); +const TODO_TEXT = __('Add todo'); + +export default { + directives: { + tooltip, + }, + components: { + Icon, + LoadingIcon, + }, + props: { + issuableId: { + type: Number, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + isTodo: { + type: Boolean, + required: false, + default: true, + }, + isActionActive: { + type: Boolean, + required: false, + default: false, + }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + buttonClasses() { + return this.collapsed ? + 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' : + 'btn btn-default btn-todo issuable-header-btn float-right'; + }, + buttonLabel() { + return this.isTodo ? MARK_TEXT : TODO_TEXT; + }, + collapsedButtonIconClasses() { + return this.isTodo ? 'todo-undone' : ''; + }, + collapsedButtonIcon() { + return this.isTodo ? 'todo-done' : 'todo-add'; + }, + }, + methods: { + handleButtonClick() { + this.$emit('toggleTodo'); + }, + }, +}; +</script> + +<template> + <button + v-tooltip + :class="buttonClasses" + :title="buttonLabel" + :aria-label="buttonLabel" + :data-issuable-id="issuableId" + :data-issuable-type="issuableType" + type="button" + data-container="body" + data-placement="left" + data-boundary="viewport" + @click="handleButtonClick" + > + <icon + v-show="collapsed" + :css-classes="collapsedButtonIconClasses" + :name="collapsedButtonIcon" + /> + <span + v-show="!collapsed" + class="issuable-todo-inner" + > + {{ buttonLabel }} + </span> + <loading-icon + v-show="isActionActive" + :inline="true" + /> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 133bdbb54f7..8163947cd0c 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -42,6 +42,9 @@ export default { }, methods: { onImgLoad() { + requestIdleCallback(this.calculateImgSize, { timeout: 1000 }); + }, + calculateImgSize() { const { contentImg } = this.$refs; if (contentImg) { diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index 3cba0c5e633..af5ebcdc40a 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -38,9 +38,17 @@ export default { v-show="isLoading" :inline="true" /> - <span class="dropdown-toggle-text"> - {{ toggleText }} - </span> + <template> + <slot + v-if="$slots.default" + ></slot> + <span + v-else + class="dropdown-toggle-text" + > + {{ toggleText }} + </span> + </template> <span v-show="!isLoading" class="dropdown-toggle-icon" diff --git a/app/assets/javascripts/vue_shared/components/reports/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index c5faa29fd2a..540df392e4e 100644 --- a/app/assets/javascripts/vue_shared/components/reports/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -4,15 +4,20 @@ import Icon from '~/vue_shared/components/icon.vue'; import { inserted } from '~/feature_highlight/feature_highlight_helper'; import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; +/** + * Render a button with a question mark icon + * On hover shows a popover. The popover will be dismissed on mouseleave + */ export default { - name: 'ReportsHelpPopover', + name: 'HelpPopover', components: { Icon, }, props: { options: { type: Object, - required: true, + required: false, + default: () => ({}), }, }, mounted() { diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index e7ff76c8218..5e0e7315e99 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -1,7 +1,7 @@ <script> // only allow classes in images.scss e.g. s12 -const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; +const validSizes = [8, 10, 12, 16, 18, 24, 32, 48, 72]; let iconValidator = () => true; /* @@ -75,6 +75,12 @@ export default { required: false, default: null, }, + + tabIndex: { + type: String, + required: false, + default: null, + }, }, computed: { @@ -98,6 +104,7 @@ export default { :height="height" :x="x" :y="y" + :tabindex="tabIndex" > <use v-bind="{ 'xlink:href':spriteHref }"/> </svg> diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue new file mode 100644 index 00000000000..17927fabbcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue @@ -0,0 +1,47 @@ +<script> +import Identicon from '../identicon.vue'; +import ProjectAvatarImage from './image.vue'; + +export default { + components: { + Identicon, + ProjectAvatarImage, + }, + props: { + project: { + type: Object, + required: true, + }, + size: { + type: Number, + default: 40, + }, + }, + computed: { + sizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <span + :class="sizeClass" + class="avatar-container project-avatar" + > + <project-avatar-image + v-if="project.avatar_url" + :link-href="project.path" + :img-src="project.avatar_url" + :img-alt="project.name" + :img-size="size" + /> + <identicon + v-else + :entity-id="project.id" + :entity-name="project.name" + :size-class="sizeClass" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/report_section.vue b/app/assets/javascripts/vue_shared/components/reports/report_section.vue index 0124d8b5bcc..a6dbf21092b 100644 --- a/app/assets/javascripts/vue_shared/components/reports/report_section.vue +++ b/app/assets/javascripts/vue_shared/components/reports/report_section.vue @@ -2,7 +2,7 @@ import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import IssuesList from './issues_list.vue'; -import Popover from './help_popover.vue'; +import Popover from '../help_popover.vue'; const LOADING = 'LOADING'; const ERROR = 'ERROR'; diff --git a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue index 28156d7c983..063beab58fc 100644 --- a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue +++ b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue @@ -1,7 +1,7 @@ <script> import CiIcon from '~/vue_shared/components/ci_icon.vue'; import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; -import Popover from './help_popover.vue'; +import Popover from '../help_popover.vue'; /** * Renders the summary row for each report diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index ac2e99abe77..80dc7d3557c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -12,6 +12,11 @@ export default { type: Boolean, required: true, }, + cssClasses: { + type: String, + required: false, + default: '', + }, }, computed: { tooltipLabel() { @@ -30,10 +35,12 @@ export default { <button v-tooltip :title="tooltipLabel" + :class="cssClasses" type="button" class="btn btn-blank gutter-toggle btn-sidebar-action" data-container="body" data-placement="left" + data-boundary="viewport" @click="toggle" > <i diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 3a413c74410..7737b9f2697 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -1,5 +1,4 @@ <script> - /* This is a re-usable vue component for rendering a user avatar that does not need to link to the user's profile. The image and an optional tooltip can be configured by props passed to this component. @@ -67,7 +66,9 @@ export default { // we provide an empty string when we use it inside user avatar link. // In both cases we should render the defaultAvatarUrl sanitizedSource() { - return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`; + return baseSrc; }, resultantSrcAttribute() { return this.lazy ? placeholderImage : this.sanitizedSource; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index d28ad407734..c20738a20c3 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -339,3 +339,13 @@ input[type=color].form-control { vertical-align: unset; } } + +// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet +.input-group-btn:first-child { + @extend .input-group-prepend; +} + +// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet +.input-group-btn:last-child { + @extend .input-group-append; +} diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index dddd07c798c..369556dc24e 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -78,6 +78,7 @@ &.s26 { font-size: 20px; line-height: 1.33; } &.s32 { font-size: 20px; line-height: 30px; } &.s40 { font-size: 16px; line-height: 38px; } + &.s48 { font-size: 20px; line-height: 46px; } &.s60 { font-size: 32px; line-height: 58px; } &.s70 { font-size: 34px; line-height: 70px; } &.s90 { font-size: 36px; line-height: 88px; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index c9865610b78..af17210f341 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -454,6 +454,7 @@ img.emoji { .prepend-left-10 { margin-left: 10px; } .prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } +.append-right-4 { margin-right: 4px; } .append-right-5 { margin-right: 5px; } .append-right-8 { margin-right: 8px; } .append-right-10 { margin-right: 10px; } @@ -470,3 +471,5 @@ img.emoji { .center { text-align: center; } .vertical-align-middle { vertical-align: middle; } .flex-align-self-center { align-self: center; } +.flex-grow { flex-grow: 1; } +.flex-no-shrink { flex-shrink: 0; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index ea4cb9a0b75..e2bbcc67a67 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -55,6 +55,11 @@ .sidebar-context-title { overflow: hidden; text-overflow: ellipsis; + + &.text-secondary { + font-weight: normal; + font-size: 0.8em; + } } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ec4a0f378d0..eebce8b9011 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -571,7 +571,8 @@ margin-bottom: 10px; padding: 0 10px; - .fa { + .fa, + .input-icon { position: absolute; top: 10px; right: 20px; diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index dff6bce370f..50ebc6d0dd1 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -3,7 +3,6 @@ */ @mixin gitlab-theme( - $location-badge-color, $search-and-nav-links, $active-tab-border, $border-and-box-shadow, @@ -119,12 +118,6 @@ } } - .location-badge { - color: $location-badge-color; - background-color: rgba($search-and-nav-links, 0.1); - border-right: 1px solid $sidebar-text; - } - .search-input::placeholder { color: rgba($search-and-nav-links, 0.8); } @@ -141,10 +134,6 @@ background-color: $white-light; } - .location-badge { - color: $gl-text-color; - } - .search-input-wrap { .search-icon { fill: rgba($search-and-nav-links, 0.8); @@ -200,7 +189,6 @@ body { &.ui-indigo { @include gitlab-theme( - $indigo-100, $indigo-200, $indigo-500, $indigo-700, @@ -212,7 +200,6 @@ body { &.ui-light-indigo { @include gitlab-theme( - $indigo-100, $indigo-200, $indigo-500, $indigo-500, @@ -224,7 +211,6 @@ body { &.ui-blue { @include gitlab-theme( - $theme-blue-100, $theme-blue-200, $theme-blue-500, $theme-blue-700, @@ -236,7 +222,6 @@ body { &.ui-light-blue { @include gitlab-theme( - $theme-light-blue-100, $theme-light-blue-200, $theme-light-blue-500, $theme-light-blue-500, @@ -248,7 +233,6 @@ body { &.ui-green { @include gitlab-theme( - $theme-green-100, $theme-green-200, $theme-green-500, $theme-green-700, @@ -260,7 +244,6 @@ body { &.ui-light-green { @include gitlab-theme( - $theme-green-100, $theme-green-200, $theme-green-500, $theme-green-500, @@ -272,7 +255,6 @@ body { &.ui-red { @include gitlab-theme( - $theme-red-100, $theme-red-200, $theme-red-500, $theme-red-700, @@ -284,7 +266,6 @@ body { &.ui-light-red { @include gitlab-theme( - $theme-light-red-100, $theme-light-red-200, $theme-light-red-500, $theme-light-red-500, @@ -296,7 +277,6 @@ body { &.ui-dark { @include gitlab-theme( - $theme-gray-100, $theme-gray-200, $theme-gray-500, $theme-gray-700, @@ -308,7 +288,6 @@ body { &.ui-light { @include gitlab-theme( - $theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-700, @@ -357,10 +336,6 @@ body { &:hover { background-color: $white-light; box-shadow: inset 0 0 0 1px $blue-200; - - .location-badge { - box-shadow: inset 0 0 0 1px $blue-200; - } } } @@ -373,13 +348,6 @@ body { color: $gl-text-color; } } - - .location-badge { - color: $theme-gray-700; - box-shadow: inset 0 0 0 1px $border-color; - background-color: $nav-badge-bg; - border-right: 0; - } } .nav-sidebar li.active { diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index ab3cceceae9..f878ec1ca91 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -39,7 +39,7 @@ svg { fill: currentColor; - $svg-sizes: 8 12 16 18 24 32 48 72; + $svg-sizes: 8 10 12 16 18 24 32 48 72; @each $svg-size in $svg-sizes { &.s#{$svg-size} { @include svg-size(#{$svg-size}px); diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 56940a7564a..4db9efff6ee 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -467,7 +467,8 @@ $award-emoji-positive-add-lines: #bb9c13; */ $search-input-border-color: rgba($blue-400, 0.8); $search-input-focus-shadow-color: $dropdown-input-focus-shadow; -$search-input-width: 220px; +$search-input-width: 240px; +$search-input-active-width: 320px; $location-badge-active-bg: $blue-500; $location-icon-color: #e7e9ed; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 58ed5bf6455..2b8163b8c68 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -1,6 +1,13 @@ @import 'framework/variables'; @import 'framework/mixins'; +$search-list-icon-width: 18px; +$ide-activity-bar-width: 60px; +$ide-context-header-padding: 10px; +$ide-project-avatar-end: $ide-context-header-padding + 48px; +$ide-tree-padding: $gl-padding; +$ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; + .project-refs-form, .project-refs-target-form { display: inline-block; @@ -24,7 +31,6 @@ display: flex; height: calc(100vh - #{$header-height}); margin-top: 0; - border-top: 1px solid $white-dark; padding-bottom: $ide-statusbar-height; color: $gl-text-color; @@ -41,10 +47,10 @@ } .ide-file-list { + display: flex; + flex-direction: column; flex: 1; - padding-left: $gl-padding; - padding-right: $gl-padding; - padding-bottom: $grid-size; + min-height: 0; .file { height: 32px; @@ -517,35 +523,30 @@ > a, > button { - height: 60px; + text-decoration: none; + padding-top: $gl-padding-8; + padding-bottom: $gl-padding-8; } } - .projects-sidebar { - min-height: 0; - display: flex; - flex-direction: column; - flex: 1; - } - .multi-file-commit-panel-inner { position: relative; display: flex; flex-direction: column; - height: 100%; + min-height: 100%; min-width: 0; width: 100%; } - .multi-file-commit-panel-inner-scroll { + .multi-file-commit-panel-inner-content { display: flex; flex: 1; flex-direction: column; - overflow: auto; background-color: $white-light; border-left: 1px solid $white-dark; border-top: 1px solid $white-dark; border-top-left-radius: $border-radius-small; + min-height: 0; } } @@ -803,12 +804,6 @@ height: calc(100vh - #{$header-height + $flash-height}); } } - - .projects-sidebar { - .multi-file-commit-panel-inner-scroll { - flex: 1; - } - } } } @@ -964,7 +959,7 @@ .ide-activity-bar { position: relative; - flex: 0 0 60px; + flex: 0 0 $ide-activity-bar-width; z-index: 1; } @@ -1060,21 +1055,56 @@ } .ide-tree-header { + flex: 0 0 auto; display: flex; align-items: center; - margin-bottom: 8px; + flex-wrap: wrap; padding: 12px 0; + margin-left: $ide-tree-padding; + margin-right: $ide-tree-padding; border-bottom: 1px solid $white-dark; .ide-new-btn { margin-left: auto; } + .ide-nav-dropdown { + width: 100%; + margin-bottom: 12px; + + .dropdown-menu { + width: 385px; + max-height: initial; + } + + .dropdown-menu-toggle { + svg { + vertical-align: middle; + } + + &:hover { + background-color: $white-normal; + } + } + + &.show { + .dropdown-menu-toggle { + background-color: $white-dark; + } + } + } + button { color: $gl-text-color; } } +.ide-tree-body { + overflow: auto; + padding-left: $ide-tree-padding; + padding-right: $ide-tree-padding; +} + .ide-sidebar-branch-title { font-weight: $gl-font-weight-normal; @@ -1163,14 +1193,23 @@ } .ide-context-header { - .avatar { - flex: 0 0 38px; - } - .ide-merge-requests-dropdown.dropdown-menu { width: 385px; max-height: initial; } + + .avatar-container { + flex: initial; + margin-right: 0; + } + + .ide-sidebar-project-title { + margin-left: $ide-tree-text-start - $ide-project-avatar-end; + } +} + +.ide-context-body { + min-height: 0; } .ide-sidebar-project-title { @@ -1178,10 +1217,11 @@ .sidebar-context-title { white-space: nowrap; - } + display: block; - .ide-sidebar-branch-title { - min-width: 50px; + &.text-secondary { + font-weight: normal; + } } } @@ -1217,6 +1257,10 @@ background-color: $white-light; border-left: 1px solid $white-dark; } + + .ide-right-sidebar-clientside { + padding: 0; + } } .ide-pipeline { @@ -1315,7 +1359,7 @@ min-height: 60px; } -.ide-merge-requests-dropdown { +.ide-nav-form { .nav-links li { width: 50%; padding-left: 0; @@ -1334,22 +1378,36 @@ padding-left: $gl-padding; padding-right: $gl-padding; - .fa { - right: 26px; + .input-icon { + right: auto; + left: 10px; + top: 50%; + transform: translateY(-50%); } } + .dropdown-input-field { + padding-left: $search-list-icon-width + $gl-padding; + padding-top: 2px; + padding-bottom: 2px; + } + + .tokens-container { + padding-left: $search-list-icon-width + $gl-padding; + overflow-x: hidden; + } + .btn-link { padding-top: $gl-padding; padding-bottom: $gl-padding; } } -.ide-merge-request-current-icon { - min-width: 18px; +.ide-search-list-current-icon { + min-width: $search-list-icon-width; } -.ide-merge-requests-empty { +.ide-search-list-empty { height: 230px; } @@ -1400,3 +1458,40 @@ color: $white-normal; background-color: $blue-500; } + +.ide-preview-header { + padding: 0 $grid-size; + border-bottom: 1px solid $white-dark; + background-color: $gray-light; + min-height: 44px; +} + +.ide-navigator-btn { + height: 24px; + min-width: 24px; + max-width: 24px; + padding: 0; + margin: 0 ($grid-size / 2); + color: $gl-gray-light; + + &:first-child { + margin-left: 0; + } +} + +.ide-navigator-location { + padding-top: ($grid-size / 2); + padding-bottom: ($grid-size / 2); + + &:focus { + outline: 0; + box-shadow: none; + border-color: $theme-gray-200; + } +} + +.ide-preview-loading-icon { + right: $grid-size; + top: 50%; + transform: translateY(-50%); +} diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 05bf5596fb3..1587aebfe1d 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -290,9 +290,8 @@ } .folder-toggle-wrap { - float: left; - line-height: $list-text-height; font-size: 0; + flex-shrink: 0; span { font-size: $gl-font-size; @@ -308,7 +307,7 @@ width: 15px; svg { - margin-bottom: 2px; + margin-bottom: 1px; } } @@ -391,9 +390,17 @@ cursor: pointer; } - .avatar-container > a { - width: 100%; - text-decoration: none; + .group-text { + min-width: 0; // allows for truncated text within flex children + } + + .avatar-container { + flex-shrink: 0; + + > a { + width: 100%; + text-decoration: none; + } } &.has-more-items { @@ -401,9 +408,18 @@ padding: 20px 10px; } + .description { + p { + @include str-truncated; + + max-width: none; + } + } + .stats { position: relative; - line-height: 46px; + line-height: normal; + flex-shrink: 0; > span { display: inline-flex; @@ -422,14 +438,20 @@ } .controls { - margin-left: 5px; + flex-shrink: 0; > .btn { - margin-right: $btn-margin-5; + margin: 0 0 0 $btn-margin-5; } } } + @include media-breakpoint-down(xs) { + .group-stats { + display: none; + } + } + .project-row-contents .stats { line-height: inherit; @@ -451,18 +473,6 @@ } } -ul.group-list-tree { - li.group-row { - > .group-row-contents .title { - line-height: $list-text-height; - } - - &.has-description > .group-row-contents .title { - line-height: inherit; - } - } -} - .js-groups-list-holder { .groups-list-loading { font-size: 34px; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index d5ae2b673d9..8e78d9f65eb 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -449,6 +449,7 @@ .todo-undone { color: $gl-link-color; + fill: $gl-link-color; } .author { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 391dfea0703..2b40404971c 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -72,6 +72,9 @@ } .manage-labels-list { + padding: 0; + margin-bottom: 0; + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; margin-bottom: 5px; @@ -81,6 +84,10 @@ border-radius: $border-radius-default; border: 1px solid $theme-gray-100; + &:last-child { + margin-bottom: 0; + } + &.sortable-ghost { opacity: 0.3; } @@ -243,7 +250,10 @@ .label-actions-list { list-style: none; flex-shrink: 0; + text-align: right; padding: 0; + position: relative; + top: -3px; } .label-badge { @@ -272,6 +282,16 @@ padding: 0; } +.label-description { + .description-text { + margin-bottom: 10px; + + .admin-labels & { + margin-bottom: 0; + } + } +} + .label-list-item { .content-list &::before, .content-list &::after { @@ -319,6 +339,64 @@ fill: $blue-600; } } + + &.remove-row { + &:hover { + color: $gl-text-red; + + svg { + fill: $gl-text-red; + } + } + } + } +} + +@media (max-width: map-get($grid-breakpoints, md)-1) { + .manage-labels-list { + > li:not(.empty-message):not(.is-not-draggable) { + flex-wrap: wrap; + } + + .label-name { + order: 1; + flex-grow: 1; + width: auto; + max-width: 100%; + } + + .label-actions-list { + order: 2; + flex-shrink: 1; + text-align: left; + } + + .label-links { + white-space: normal; + } + + .label-description { + order: 3; + width: 100%; + + > .append-right-default.prepend-left-default { + margin-left: 0; + margin-right: 0; + } + } + } +} + +@media (max-width: 910px) { + .priority-badge { + display: block; + width: 100%; + margin-left: 0; + margin-top: $gl-padding; + + .label-badge { + display: inline-block; + } } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index a355ceea7a0..621321101cd 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -87,7 +87,7 @@ padding: 0; } - .grouped-security-reports { + .mr-report { padding: 0; > .media { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 7fc2936c5e6..c369d89d63c 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -546,6 +546,7 @@ ul.notes { svg { @include btn-svg; + margin: 0; } .award-control-icon-positive, diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 5d0d59e12f2..b45e305897c 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -418,3 +418,23 @@ table.u2f-registrations { } } } + +.edit-user { + .clear-user-status { + svg { + fill: $gl-text-color-secondary; + } + } + + .emoji-menu-toggle-button { + @extend .note-action-button; + + .no-emoji-placeholder { + position: relative; + + svg { + fill: $gl-text-color-secondary; + } + } + } +} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 2d66f336076..60b280fd12e 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -1,3 +1,6 @@ +$search-dropdown-max-height: 400px; +$search-avatar-size: 16px; + .search-results { .search-result-row { border-bottom: 1px solid $border-color; @@ -24,8 +27,9 @@ box-shadow: 0 0 4px lighten($search-input-focus-shadow-color, 20%); } -input[type="checkbox"]:hover { - box-shadow: 0 0 2px 2px lighten($search-input-focus-shadow-color, 20%), 0 0 0 1px lighten($search-input-focus-shadow-color, 20%); +input[type='checkbox']:hover { + box-shadow: 0 0 2px 2px lighten($search-input-focus-shadow-color, 20%), + 0 0 0 1px lighten($search-input-focus-shadow-color, 20%); } .search { @@ -40,24 +44,15 @@ input[type="checkbox"]:hover { height: 32px; border: 0; border-radius: $border-radius-default; - transition: border-color ease-in-out $default-transition-duration, background-color ease-in-out $default-transition-duration; + transition: border-color ease-in-out $default-transition-duration, + background-color ease-in-out $default-transition-duration, + width ease-in-out $default-transition-duration; &:hover { box-shadow: none; } } - .location-badge { - white-space: nowrap; - height: 32px; - font-size: 12px; - margin: -4px 4px -4px -4px; - line-height: 25px; - padding: 4px 8px; - border-radius: $border-radius-default 0 0 $border-radius-default; - transition: border-color ease-in-out $default-transition-duration; - } - .search-input { border: 0; font-size: 14px; @@ -104,17 +99,28 @@ input[type="checkbox"]:hover { } .dropdown-header { - text-transform: uppercase; - font-size: 11px; + // Necessary because glDropdown doesn't support a second style of headers + font-weight: $gl-font-weight-bold; + // .dropdown-menu li has 1px side padding + padding: $gl-padding-8 17px; + color: $gl-text-color; + font-size: $gl-font-size; + line-height: 16px; } // Custom dropdown positioning .dropdown-menu { left: -5px; + max-height: $search-dropdown-max-height; + overflow: auto; + + @include media-breakpoint-up(xl) { + width: $search-input-active-width; + } } .dropdown-content { - max-height: none; + max-height: $search-dropdown-max-height - 18px; } } @@ -124,6 +130,10 @@ input[type="checkbox"]:hover { border-color: $dropdown-input-focus-border; box-shadow: none; + @include media-breakpoint-up(xl) { + width: $search-input-active-width; + } + .search-input-wrap { .search-icon, .clear-icon { @@ -141,12 +151,6 @@ input[type="checkbox"]:hover { color: $gl-text-color-tertiary; } } - - .location-badge { - transition: all $default-transition-duration; - background-color: $nav-badge-bg; - border-color: $border-color; - } } &.has-value { @@ -160,10 +164,24 @@ input[type="checkbox"]:hover { } } - &.has-location-badge { - .search-input-wrap { - width: 68%; - } + .inline-search-icon { + position: relative; + margin-right: 4px; + color: $gl-text-color-secondary; + } + + .identicon, + .search-item-avatar { + flex-basis: $search-avatar-size; + flex-shrink: 0; + margin-right: 4px; + } + + .search-item-avatar { + width: $search-avatar-size; + height: $search-avatar-size; + border-radius: 50%; + border: 1px solid $avatar-border; } } diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index 777fdb3581e..239123fc3ab 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -19,9 +19,4 @@ .auto-devops-card { margin-bottom: $gl-vert-padding; - - > .card-body { - border-radius: $card-border-radius; - padding: $gl-padding $gl-padding-24; - } } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index e5d7dd13915..010a2c05a1c 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -174,6 +174,18 @@ } } +@include media-breakpoint-down(lg) { + .todos-filters { + .filter-categories { + width: 75%; + + .filter-item { + margin-bottom: 10px; + } + } + } +} + @include media-breakpoint-down(xs) { .todo { .avatar { @@ -199,6 +211,10 @@ } .todos-filters { + .filter-categories { + width: auto; + } + .dropdown-menu-toggle { width: 100%; } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 783831748a7..05ed3669a41 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -20,13 +20,13 @@ class ApplicationController < ActionController::Base before_action :ldap_security_check before_action :sentry_context before_action :default_headers - before_action :add_gon_variables, unless: :peek_request? + before_action :add_gon_variables, unless: [:peek_request?, :json_request?] before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? around_action :set_locale - after_action :set_page_title_header, if: -> { request.format == :json } + after_action :set_page_title_header, if: :json_request? protect_from_forgery with: :exception, prepend: true @@ -35,6 +35,7 @@ class ApplicationController < ActionController::Base :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, + :bitbucket_server_import_enabled?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?, :manifest_import_enabled? @@ -337,6 +338,10 @@ class ApplicationController < ActionController::Base !Gitlab::CurrentSettings.import_sources.empty? end + def bitbucket_server_import_enabled? + Gitlab::CurrentSettings.import_sources.include?('bitbucket_server') + end + def github_import_enabled? Gitlab::CurrentSettings.import_sources.include?('github') end @@ -419,6 +424,10 @@ class ApplicationController < ActionController::Base request.path.start_with?('/-/peek') end + def json_request? + request.format.json? + end + def should_enforce_terms? return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms diff --git a/app/controllers/concerns/repository_settings_redirect.rb b/app/controllers/concerns/repository_settings_redirect.rb index 0576f0e6e70..f3db3cd563b 100644 --- a/app/controllers/concerns/repository_settings_redirect.rb +++ b/app/controllers/concerns/repository_settings_redirect.rb @@ -1,7 +1,7 @@ module RepositorySettingsRedirect extend ActiveSupport::Concern - def redirect_to_repository_settings(project) - redirect_to project_settings_repository_path(project) + def redirect_to_repository_settings(project, anchor: nil) + redirect_to project_settings_repository_path(project, anchor: anchor) end end diff --git a/app/controllers/concerns/todos_actions.rb b/app/controllers/concerns/todos_actions.rb new file mode 100644 index 00000000000..c0acdb3498d --- /dev/null +++ b/app/controllers/concerns/todos_actions.rb @@ -0,0 +1,12 @@ +module TodosActions + extend ActiveSupport::Concern + + def create + todo = TodoService.new.mark_todo(issuable, current_user) + + render json: { + count: TodosFinder.new(current_user, state: :pending).execute.count, + delete_path: dashboard_todo_path(todo) + } + end +end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index f9e8fe624e8..bd7111e28bc 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController end def todo_params - params.permit(:action_id, :author_id, :project_id, :type, :sort, :state) + params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id) end def redirect_out_of_range(todos) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 79fa5818359..83169636ccf 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -85,7 +85,7 @@ class GroupsController < Groups::ApplicationController def update if Groups::UpdateService.new(@group, current_user, group_params).execute - redirect_to edit_group_path(@group), notice: "Group '#{@group.name}' was successfully updated." + redirect_to edit_group_path(@group, anchor: params[:update_section]), notice: "Group '#{@group.name}' was successfully updated." else @group.restore_path! diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb new file mode 100644 index 00000000000..798daeca6c9 --- /dev/null +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +class Import::BitbucketServerController < Import::BaseController + before_action :verify_bitbucket_server_import_enabled + before_action :bitbucket_auth, except: [:new, :configure] + before_action :validate_import_params, only: [:create] + + # As a basic sanity check to prevent URL injection, restrict project + # repository input and repository slugs to allowed characters. For Bitbucket: + # + # Project keys must start with a letter and may only consist of ASCII letters, numbers and underscores (A-Z, a-z, 0-9, _). + # + # Repository names are limited to 128 characters. They must start with a + # letter or number and may contain spaces, hyphens, underscores, and periods. + # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054) + VALID_BITBUCKET_CHARS = /\A[\w\-_\.\s]+\z/ + + def new + end + + def create + repo = bitbucket_client.repo(@project_key, @repo_slug) + + unless repo + return render json: { errors: "Project #{@project_key}/#{@repo_slug} could not be found" }, status: :unprocessable_entity + end + + project_name = params[:new_name].presence || repo.name + namespace_path = params[:new_namespace].presence || current_user.username + target_namespace = find_or_create_namespace(namespace_path, current_user) + + if current_user.can?(:create_projects, target_namespace) + project = Gitlab::BitbucketServerImport::ProjectCreator.new(@project_key, @repo_slug, repo, project_name, target_namespace, current_user, credentials).execute + + if project.persisted? + render json: ProjectSerializer.new.represent(project) + else + render json: { errors: project_save_error(project) }, status: :unprocessable_entity + end + else + render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity + end + rescue BitbucketServer::Client::ServerError => e + render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity + end + + def configure + session[personal_access_token_key] = params[:personal_access_token] + session[bitbucket_server_username_key] = params[:bitbucket_username] + session[bitbucket_server_url_key] = params[:bitbucket_server_url] + + redirect_to status_import_bitbucket_server_path + end + + def status + repos = bitbucket_client.repos + + @repos, @incompatible_repos = repos.partition { |repo| repo.valid? } + + @already_added_projects = find_already_added_projects('bitbucket_server') + already_added_projects_names = @already_added_projects.pluck(:import_source) + + @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.browse_url) } + rescue BitbucketServer::Connection::ConnectionError, BitbucketServer::Client::ServerError => e + flash[:alert] = "Unable to connect to server: #{e}" + clear_session_data + redirect_to new_import_bitbucket_server_path + end + + def jobs + render json: find_jobs('bitbucket_server') + end + + private + + def bitbucket_client + @bitbucket_client ||= BitbucketServer::Client.new(credentials) + end + + def validate_import_params + @project_key = params[:project] + @repo_slug = params[:repository] + + return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present? + return render_validation_error('Missing repository slug') unless @repo_slug.present? + return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_CHARS + return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS + end + + def render_validation_error(message) + render json: { errors: message }, status: :unprocessable_entity + end + + def bitbucket_auth + unless session[bitbucket_server_url_key].present? && + session[bitbucket_server_username_key].present? && + session[personal_access_token_key].present? + redirect_to new_import_bitbucket_server_path + end + end + + def verify_bitbucket_server_import_enabled + render_404 unless bitbucket_server_import_enabled? + end + + def bitbucket_server_url_key + :bitbucket_server_url + end + + def bitbucket_server_username_key + :bitbucket_server_username + end + + def personal_access_token_key + :bitbucket_server_personal_access_token + end + + def clear_session_data + session[bitbucket_server_url_key] = nil + session[bitbucket_server_username_key] = nil + session[personal_access_token_key] = nil + end + + def credentials + { + base_uri: session[bitbucket_server_url_key], + user: session[bitbucket_server_username_key], + password: session[personal_access_token_key] + } + end +end diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb index a13d552dbd8..53fdc5843b5 100644 --- a/app/controllers/projects/avatars_controller.rb +++ b/app/controllers/projects/avatars_controller.rb @@ -21,6 +21,6 @@ class Projects::AvatarsController < Projects::ApplicationController @project.save - redirect_to edit_project_path(@project), status: :found + redirect_to edit_project_path(@project, anchor: 'js-general-project-settings'), status: :found end end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 06739d8fd4a..28fea322334 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -10,7 +10,7 @@ class Projects::DeployKeysController < Projects::ApplicationController def index respond_to do |format| - format.html { redirect_to_repository_settings(@project) } + format.html { redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') } format.json do render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json end @@ -18,7 +18,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def new - redirect_to_repository_settings(@project) + redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') end def create @@ -28,7 +28,7 @@ class Projects::DeployKeysController < Projects::ApplicationController flash[:alert] = @key.errors.full_messages.join(', ').html_safe end - redirect_to_repository_settings(@project) + redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') end def edit @@ -37,7 +37,7 @@ class Projects::DeployKeysController < Projects::ApplicationController def update if deploy_key.update(update_params) flash[:notice] = 'Deploy key was successfully updated.' - redirect_to_repository_settings(@project) + redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') else render 'edit' end @@ -47,7 +47,7 @@ class Projects::DeployKeysController < Projects::ApplicationController Projects::EnableDeployKeyService.new(@project, current_user, params).execute respond_to do |format| - format.html { redirect_to_repository_settings(@project) } + format.html { redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') } format.json { head :ok } end end @@ -59,7 +59,7 @@ class Projects::DeployKeysController < Projects::ApplicationController deploy_key_project.destroy! respond_to do |format| - format.html { redirect_to_repository_settings(@project) } + format.html { redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') } format.json { head :ok } end end diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb index 2f91b8f36de..83abda64fe0 100644 --- a/app/controllers/projects/deploy_tokens_controller.rb +++ b/app/controllers/projects/deploy_tokens_controller.rb @@ -5,6 +5,6 @@ class Projects::DeployTokensController < Projects::ApplicationController @token = @project.deploy_tokens.find(params[:id]) @token.revoke! - redirect_to project_settings_repository_path(project) + redirect_to project_settings_repository_path(project, anchor: 'js-deploy-tokens') end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index eaf4434f913..1b069fe507b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -102,10 +102,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def test_reports result = @merge_request.compare_test_reports - Gitlab::PollingInterval.set_header(response, interval: 10_000) - case result[:status] when :parsing + Gitlab::PollingInterval.set_header(response, interval: 3000) + render json: '', status: :no_content when :parsed render json: result[:data].to_json, status: :ok diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index da209c3ba61..3739608e4c0 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -9,7 +9,7 @@ class Projects::MirrorsController < Projects::ApplicationController layout "project_settings" def show - redirect_to_repository_settings(project) + redirect_to_repository_settings(project, anchor: 'js-push-remote-settings') end def update @@ -22,7 +22,7 @@ class Projects::MirrorsController < Projects::ApplicationController end respond_to do |format| - format.html { redirect_to_repository_settings(project) } + format.html { redirect_to_repository_settings(project, anchor: 'js-push-remote-settings') } format.json do if project.errors.present? render json: project.errors, status: :unprocessable_entity @@ -39,7 +39,7 @@ class Projects::MirrorsController < Projects::ApplicationController flash[:notice] = "The remote repository is being updated..." end - redirect_to_repository_settings(project) + redirect_to_repository_settings(project, anchor: 'js-push-remote-settings') end private diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index 9e757a8d25f..cc62ce2f11b 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -19,7 +19,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController flash[:alert] = protected_ref.errors.full_messages.join(', ').html_safe end - redirect_to_repository_settings(@project) + redirect_to_repository_settings(@project, anchor: params[:update_section]) end def show @@ -40,7 +40,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController destroy_service_class.new(@project, current_user).execute(@protected_ref) respond_to do |format| - format.html { redirect_to_repository_settings(@project) } + format.html { redirect_to_repository_settings(@project, anchor: params[:update_section]) } format.js { head :ok } end end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index cc7cce887bf..d118cec977c 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -5,7 +5,7 @@ class Projects::RunnersController < Projects::ApplicationController layout 'project_settings' def index - redirect_to project_settings_ci_cd_path(@project) + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') end def edit @@ -50,13 +50,13 @@ class Projects::RunnersController < Projects::ApplicationController def toggle_shared_runners project.toggle!(:shared_runners_enabled) - redirect_to project_settings_ci_cd_path(@project) + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') end def toggle_group_runners project.toggle_ci_cd_settings!(:group_runners_enabled) - redirect_to project_settings_ci_cd_path(@project) + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') end protected diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index a41fcb85c40..93fb9da6510 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -1,19 +1,13 @@ class Projects::TodosController < Projects::ApplicationController - before_action :authenticate_user!, only: [:create] - - def create - todo = TodoService.new.mark_todo(issuable, current_user) + include Gitlab::Utils::StrongMemoize + include TodosActions - render json: { - count: TodosFinder.new(current_user, state: :pending).execute.count, - delete_path: dashboard_todo_path(todo) - } - end + before_action :authenticate_user!, only: [:create] private def issuable - @issuable ||= begin + strong_memoize(:issuable) do case params[:issuable_type] when "issue" IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id]) diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index 6f3de43f85a..cb12b707087 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -7,7 +7,7 @@ class Projects::TriggersController < Projects::ApplicationController layout 'project_settings' def index - redirect_to project_settings_ci_cd_path(@project) + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers') end def create @@ -19,7 +19,7 @@ class Projects::TriggersController < Projects::ApplicationController flash[:alert] = 'You could not create a new trigger.' end - redirect_to project_settings_ci_cd_path(@project) + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers') end def take_ownership @@ -29,7 +29,7 @@ class Projects::TriggersController < Projects::ApplicationController flash[:alert] = 'You could not take ownership of trigger.' end - redirect_to project_settings_ci_cd_path(@project) + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers') end def edit @@ -37,7 +37,7 @@ class Projects::TriggersController < Projects::ApplicationController def update if trigger.update(trigger_params) - redirect_to project_settings_ci_cd_path(@project), notice: 'Trigger was successfully updated.' + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: 'Trigger was successfully updated.' else render action: "edit" end @@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController flash[:alert] = "Could not remove the trigger." end - redirect_to project_settings_ci_cd_path(@project), status: :found + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), status: :found end private diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 9d1c44db137..e9ae8c13142 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -61,7 +61,7 @@ class ProjectsController < Projects::ApplicationController flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name } format.html do - redirect_to(edit_project_path(@project)) + redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings')) end else flash.now[:alert] = result[:message] @@ -148,7 +148,7 @@ class ProjectsController < Projects::ApplicationController def archive return access_denied! unless can?(current_user, :archive_project, @project) - @project.archive! + ::Projects::UpdateService.new(@project, current_user, archived: true).execute respond_to do |format| format.html { redirect_to project_path(@project) } @@ -158,7 +158,7 @@ class ProjectsController < Projects::ApplicationController def unarchive return access_denied! unless can?(current_user, :archive_project, @project) - @project.unarchive! + ::Projects::UpdateService.new(@project, current_user, archived: false).execute respond_to do |format| format.html { redirect_to project_path(@project) } @@ -174,7 +174,7 @@ class ProjectsController < Projects::ApplicationController ) rescue ::Projects::HousekeepingService::LeaseTaken => ex redirect_to( - edit_project_path(@project), + edit_project_path(@project, anchor: 'js-project-advanced-settings'), alert: ex.to_s ) end @@ -183,7 +183,7 @@ class ProjectsController < Projects::ApplicationController @project.add_export_job(current_user: current_user) redirect_to( - edit_project_path(@project), + edit_project_path(@project, anchor: 'js-export-project'), notice: _("Project export started. A download link will be sent by email.") ) end @@ -195,7 +195,7 @@ class ProjectsController < Projects::ApplicationController send_file export_project_path, disposition: 'attachment' else redirect_to( - edit_project_path(@project), + edit_project_path(@project, anchor: 'js-export-project'), alert: _("Project export link has expired. Please generate a new export from your project settings.") ) end @@ -208,7 +208,7 @@ class ProjectsController < Projects::ApplicationController flash[:alert] = _("Project export could not be deleted.") end - redirect_to(edit_project_path(@project)) + redirect_to(edit_project_path(@project, anchor: 'js-export-project')) end def generate_new_export @@ -216,7 +216,7 @@ class ProjectsController < Projects::ApplicationController export else redirect_to( - edit_project_path(@project), + edit_project_path(@project, anchor: 'js-export-project'), alert: _("Project export could not be deleted.") ) end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 09e2c586f2a..6e9c8ea6fde 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -15,6 +15,7 @@ class TodosFinder prepend FinderWithCrossProjectAccess include FinderMethods + include Gitlab::Utils::StrongMemoize requires_cross_project_access unless: -> { project? } @@ -34,6 +35,7 @@ class TodosFinder items = by_author(items) items = by_state(items) items = by_type(items) + items = by_group(items) # Filtering by project HAS TO be the last because we use # the project IDs yielded by the todos query thus far items = by_project(items) @@ -82,6 +84,10 @@ class TodosFinder params[:project_id].present? end + def group? + params[:group_id].present? + end + def project return @project if defined?(@project) @@ -89,10 +95,6 @@ class TodosFinder @project = Project.find(params[:project_id]) @project = nil if @project.pending_delete? - - unless Ability.allowed?(current_user, :read_project, @project) - @project = nil - end else @project = nil end @@ -100,18 +102,14 @@ class TodosFinder @project end - def project_ids(items) - ids = items.except(:order).select(:project_id) - if Gitlab::Database.mysql? - # To make UPDATE work on MySQL, wrap it in a SELECT with an alias - ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t") + def group + strong_memoize(:group) do + Group.find(params[:group_id]) end - - ids end def type? - type.present? && %w(Issue MergeRequest).include?(type) + type.present? && %w(Issue MergeRequest Epic).include?(type) end def type @@ -148,12 +146,23 @@ class TodosFinder def by_project(items) if project? - items.where(project: project) - else - projects = Project.public_or_visible_to_user(current_user) + items = items.where(project: project) + end - items.joins(:project).merge(projects) + items + end + + def by_group(items) + if group? + groups = group.self_and_descendants + project_todos = items.where(project_id: Project.where(group: groups).select(:id)) + group_todos = items.where(group_id: groups.select(:id)) + + union = Gitlab::SQL::Union.new([project_todos, group_todos]) + items = Todo.from("(#{union.to_sql}) #{Todo.table_name}") end + + items end def by_state(items) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index a9499140f8a..2bdf2c2c120 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -255,7 +255,8 @@ module ApplicationSettingsHelper :instance_statistics_visibility_private, :user_default_external, :user_oauth_applications, - :version_check_enabled + :version_check_enabled, + :web_ide_clientside_preview_enabled ] end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 678fed9c414..c84ed8091c3 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -131,6 +131,19 @@ module IssuablesHelper end end + def group_dropdown_label(group_id, default_label) + return default_label if group_id.nil? + return "Any group" if group_id == "0" + + group = ::Group.find_by(id: group_id) + + if group + group.full_name + else + default_label + end + end + def milestone_dropdown_label(milestone_title, default_label = "Milestone") title = case milestone_title diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 9008db1b300..30585cb403d 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -3,19 +3,28 @@ module NamespacesHelper params.dig(:project, :namespace_id) || params[:namespace_id] end - def namespaces_options(selected = :current_user, display_path: false, extra_group: nil, groups_only: false) - groups = current_user.manageable_groups - .joins(:route) - .includes(:route) - .order('routes.path') + def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false) + groups ||= current_user.manageable_groups + .eager_load(:route) + .order('routes.path') users = [current_user.namespace] + selected_id = selected unless extra_group.nil? || extra_group.is_a?(Group) extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group' end - if extra_group && extra_group.is_a?(Group) && (!Group.exists?(name: extra_group.name) || Ability.allowed?(current_user, :read_group, extra_group)) - groups |= [extra_group] + if extra_group && extra_group.is_a?(Group) + extra_group = dedup_extra_group(extra_group) + + if Ability.allowed?(current_user, :read_group, extra_group) + # Assign the value to an invalid primary ID so that the select box works + extra_group.id = -1 unless extra_group.persisted? + selected_id = extra_group.id if selected == :extra_group + groups |= [extra_group] + else + selected_id = current_user.namespace.id + end end options = [] @@ -25,11 +34,11 @@ module NamespacesHelper options << options_for_group(users, display_path: display_path, type: 'user') if selected == :current_user && current_user.namespace - selected = current_user.namespace.id + selected_id = current_user.namespace.id end end - grouped_options_for_select(options, selected) + grouped_options_for_select(options, selected_id) end def namespace_icon(namespace, size = 40) @@ -42,6 +51,17 @@ module NamespacesHelper private + # Many importers create a temporary Group, so use the real + # group if one exists by that name to prevent duplicates. + def dedup_extra_group(extra_group) + unless extra_group.persisted? + existing_group = Group.find_by(name: extra_group.name) + extra_group = existing_group if existing_group&.persisted? + end + + extra_group + end + def options_for_group(namespaces, display_path:, type:) group_label = type.pluralize elements = namespaces.sort_by(&:human_name).map! do |n| diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index a6a57db3002..e7aa92e6e5c 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -9,8 +9,4 @@ module ProfilesHelper end end end - - def show_user_status_field? - Feature.enabled?(:user_status_form) || cookies[:feature_user_status_form] == 'true' - end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index cadb88ba632..98074a4c0c5 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -82,16 +82,16 @@ module SearchHelper ref = @ref || @project.repository.root_ref [ - { category: "Current Project", label: "Files", url: project_tree_path(@project, ref) }, - { category: "Current Project", label: "Commits", url: project_commits_path(@project, ref) }, - { category: "Current Project", label: "Network", url: project_network_path(@project, ref) }, - { category: "Current Project", label: "Graph", url: project_graph_path(@project, ref) }, - { category: "Current Project", label: "Issues", url: project_issues_path(@project) }, - { category: "Current Project", label: "Merge Requests", url: project_merge_requests_path(@project) }, - { category: "Current Project", label: "Milestones", url: project_milestones_path(@project) }, - { category: "Current Project", label: "Snippets", url: project_snippets_path(@project) }, - { category: "Current Project", label: "Members", url: project_project_members_path(@project) }, - { category: "Current Project", label: "Wiki", url: project_wikis_path(@project) } + { category: "In this project", label: "Files", url: project_tree_path(@project, ref) }, + { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) }, + { category: "In this project", label: "Network", url: project_network_path(@project, ref) }, + { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) }, + { category: "In this project", label: "Issues", url: project_issues_path(@project) }, + { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) }, + { category: "In this project", label: "Milestones", url: project_milestones_path(@project) }, + { category: "In this project", label: "Snippets", url: project_snippets_path(@project) }, + { category: "In this project", label: "Members", url: project_project_members_path(@project) }, + { category: "In this project", label: "Wiki", url: project_wikis_path(@project) } ] else [] diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index f7620e0b6b8..7cd74358168 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -43,7 +43,7 @@ module TodosHelper project_commit_path(todo.project, todo.target, anchor: anchor) else - path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target] + path = [todo.parent, todo.target] path.unshift(:pipelines) if todo.build_failed? @@ -167,4 +167,12 @@ module TodosHelper def show_todo_state?(todo) (todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state) end + + def todo_group_options + groups = current_user.authorized_groups.map do |group| + { id: group.id, text: group.full_name } + end + + groups.unshift({ id: '', text: 'Any Group' }).to_json + end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 75dfa00d12e..e4aed76f611 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -606,12 +606,12 @@ module Ci end def has_test_reports? - complete? && builds.with_test_reports.any? + complete? && builds.latest.with_test_reports.any? end def test_reports Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| - builds.with_test_reports.each do |build| + builds.latest.with_test_reports.each do |build| build.collect_test_reports!(test_reports) end end diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 61df6174c86..55bbf7cae7e 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -1,15 +1,28 @@ # frozen_string_literal: true +require 'openssl' + module Clusters module Applications class Helm < ActiveRecord::Base self.table_name = 'clusters_applications_helm' + attr_encrypted :ca_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-cbc' + include ::Clusters::Concerns::ApplicationCore include ::Clusters::Concerns::ApplicationStatus default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION + before_create :create_keys_and_certs + + def issue_client_cert + ca_cert_obj.issue + end + def set_initial_status return unless not_installable? @@ -17,7 +30,41 @@ module Clusters end def install_command - Gitlab::Kubernetes::Helm::InitCommand.new(name) + Gitlab::Kubernetes::Helm::InitCommand.new( + name: name, + files: files + ) + end + + def has_ssl? + ca_key.present? && ca_cert.present? + end + + private + + def files + { + 'ca.pem': ca_cert, + 'cert.pem': tiller_cert.cert_string, + 'key.pem': tiller_cert.key_string + } + end + + def create_keys_and_certs + ca_cert = Gitlab::Kubernetes::Helm::Certificate.generate_root + self.ca_key = ca_cert.key_string + self.ca_cert = ca_cert.cert_string + end + + def tiller_cert + @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::Certificate::INFINITE_EXPIRY) + end + + def ca_cert_obj + return unless has_ssl? + + Gitlab::Kubernetes::Helm::Certificate + .from_strings(ca_key, ca_cert) end end end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 2440efe76ab..93f654e0638 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -37,10 +37,10 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( - name, + name: name, version: VERSION, chart: chart, - values: values + files: files ) end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index 33d54ba86fe..ef1c76c03bd 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -38,10 +38,10 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( - name, + name: name, version: VERSION, chart: chart, - values: values, + files: files, repository: repository ) end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index ccb415b3fe2..88399dbbb95 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -46,10 +46,10 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( - name, + name: name, version: VERSION, chart: chart, - values: values + files: files ) end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 426aed91089..bde255723c8 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -31,10 +31,10 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( - name, + name: name, version: VERSION, chart: chart, - values: values, + files: files, repository: repository ) end diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb index 14e004b9a57..52498f123ff 100644 --- a/app/models/clusters/concerns/application_data.rb +++ b/app/models/clusters/concerns/application_data.rb @@ -14,8 +14,34 @@ module Clusters File.read(chart_values_file) end + def files + @files ||= begin + files = { 'values.yaml': values } + + files.merge!(certificate_files) if cluster.application_helm.has_ssl? + + files + end + end + private + def certificate_files + { + 'ca.pem': ca_cert, + 'cert.pem': helm_cert.cert_string, + 'key.pem': helm_cert.key_string + } + end + + def ca_cert + cluster.application_helm.ca_cert + end + + def helm_cert + @helm_cert ||= cluster.application_helm.issue_client_cert + end + def chart_values_file "#{Rails.root}/vendor/#{name}/values.yaml" end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb index d502e7e54c6..31ee3a4da73 100644 --- a/app/models/concerns/access_requestable.rb +++ b/app/models/concerns/access_requestable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == AccessRequestable concern # # Contains functionality related to objects that can receive request for access. diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb index ff52ca64459..cbd63ba8876 100644 --- a/app/models/concerns/artifact_migratable.rb +++ b/app/models/concerns/artifact_migratable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Adapter class to unify the interface between mounted uploaders and the # Ci::Artifact model # Meant to be prepended so the interface can stay the same diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 5e39676b24b..7f6d48d972c 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Include atomic internal id generation scheme for a model # # This allows us to atomically generate internal ids that are diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 095897b08e3..c0233661a9b 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Avatarable extend ActiveSupport::Concern @@ -19,7 +21,7 @@ module Avatarable # We use avatar_path instead of overriding avatar_url because of carrierwave. # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 - avatar_path(only_path: args.fetch(:only_path, true)) || super + avatar_path(only_path: args.fetch(:only_path, true), size: args[:size]) || super end def retrieve_upload(identifier, paths) @@ -40,12 +42,13 @@ module Avatarable end end - def avatar_path(only_path: true) + def avatar_path(only_path: true, size: nil) return unless self[:avatar].present? asset_host = ActionController::Base.asset_host use_asset_host = asset_host.present? use_authentication = respond_to?(:public?) && !public? + query_params = size&.nonzero? ? "?width=#{size}" : "" # Avatars for private and internal groups and projects require authentication to be viewed, # which means they can only be served by Rails, on the regular GitLab host. @@ -56,7 +59,8 @@ module Avatarable only_path = false end - url_base = "" + url_base = [] + if use_asset_host url_base << asset_host unless only_path else @@ -64,7 +68,7 @@ module Avatarable url_base << gitlab_config.relative_url_root end - url_base + avatar.local_url + url_base.join + avatar.local_url + query_params end # Path that is persisted in the tracking Upload model. Used to fetch the diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index fce37e7f78e..dd07f389fa5 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Awardable extend ActiveSupport::Concern diff --git a/app/models/concerns/batch_destroy_dependent_associations.rb b/app/models/concerns/batch_destroy_dependent_associations.rb index 353ee2e73d0..45fbc88fbba 100644 --- a/app/models/concerns/batch_destroy_dependent_associations.rb +++ b/app/models/concerns/batch_destroy_dependent_associations.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Provides a way to work around Rails issue where dependent objects are all # loaded into memory before destroyed: https://github.com/rails/rails/issues/22510. # diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb index adb81561000..e96fefe81c4 100644 --- a/app/models/concerns/blob_like.rb +++ b/app/models/concerns/blob_like.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BlobLike extend ActiveSupport::Concern include Linguist::BlobHelper diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb index 8019e6adc1c..d346da1ba4b 100644 --- a/app/models/concerns/blocks_json_serialization.rb +++ b/app/models/concerns/blocks_json_serialization.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Overrides `as_json` and `to_json` to raise an exception when called in order # to prevent accidentally exposing attributes # diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb index 984c4f53bf7..c4346d5dd17 100644 --- a/app/models/concerns/bulk_member_access_load.rb +++ b/app/models/concerns/bulk_member_access_load.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Returns and caches in thread max member access for a resource # module BulkMemberAccessLoad diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index b05bf909058..6e2adc76ec6 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This module takes care of updating cache columns for Markdown-containing # fields. Use like this in the body of your class: # diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index 606549b947f..62b78c3611c 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CacheableAttributes extend ActiveSupport::Concern diff --git a/app/models/concerns/case_sensitivity.rb b/app/models/concerns/case_sensitivity.rb index 034e9f40ff0..0ba542b75ab 100644 --- a/app/models/concerns/case_sensitivity.rb +++ b/app/models/concerns/case_sensitivity.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Concern for querying columns with specific case sensitivity handling. module CaseSensitivity extend ActiveSupport::Concern diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb index 593a9b3d71d..edf6ac96730 100644 --- a/app/models/concerns/chronic_duration_attribute.rb +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ChronicDurationAttribute extend ActiveSupport::Concern diff --git a/app/models/concerns/created_at_filterable.rb b/app/models/concerns/created_at_filterable.rb index e8a3e41203d..a1f46478b6f 100644 --- a/app/models/concerns/created_at_filterable.rb +++ b/app/models/concerns/created_at_filterable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CreatedAtFilterable extend ActiveSupport::Concern diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 52851b3d0b2..91052013592 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DeploymentPlatform # EE would override this and utilize environment argument # rubocop:disable Gitlab/ModuleWithInstanceVariables diff --git a/app/models/concerns/diff_file.rb b/app/models/concerns/diff_file.rb index 72332072012..47ea14163dc 100644 --- a/app/models/concerns/diff_file.rb +++ b/app/models/concerns/diff_file.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffFile extend ActiveSupport::Concern diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 8b3c55387b3..c180d7b7c9a 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Contains functionality shared between `DiffDiscussion` and `LegacyDiffDiscussion`. module DiscussionOnDiff extend ActiveSupport::Concern diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 6ddbb8da1a9..a9e14cb55eb 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EachBatch extend ActiveSupport::Concern diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb index c0a3099f676..2e49e720ac9 100644 --- a/app/models/concerns/editable.rb +++ b/app/models/concerns/editable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Editable extend ActiveSupport::Concern diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb index 6b37903da20..23acfe9a55f 100644 --- a/app/models/concerns/enum_with_nil.rb +++ b/app/models/concerns/enum_with_nil.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EnumWithNil extend ActiveSupport::Concern diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb index b66ba08dc59..1f274487935 100644 --- a/app/models/concerns/expirable.rb +++ b/app/models/concerns/expirable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Expirable extend ActiveSupport::Concern diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb index 7ea042c6742..65ed46ea202 100644 --- a/app/models/concerns/fast_destroy_all.rb +++ b/app/models/concerns/fast_destroy_all.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ## # This module is for replacing `dependent: :destroy` and `before_destroy` hooks. # diff --git a/app/models/concerns/faster_cache_keys.rb b/app/models/concerns/faster_cache_keys.rb index 5b14723fa2d..312a9aa9305 100644 --- a/app/models/concerns/faster_cache_keys.rb +++ b/app/models/concerns/faster_cache_keys.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module FasterCacheKeys # A faster version of Rails' "cache_key" method. # diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb index 5db64fe82c4..3f84de54ad5 100644 --- a/app/models/concerns/feature_gate.rb +++ b/app/models/concerns/feature_gate.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module FeatureGate def flipper_id return nil if new_record? diff --git a/app/models/concerns/ghost_user.rb b/app/models/concerns/ghost_user.rb index da696127a80..15278c431fb 100644 --- a/app/models/concerns/ghost_user.rb +++ b/app/models/concerns/ghost_user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module GhostUser extend ActiveSupport::Concern diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 5e9a95c3282..05cd4265133 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module GroupDescendant # Returns the hierarchy of a project or group in the from of a hash upto a # given top. diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 72c236a0fc7..b3960cbad1a 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module HasStatus extend ActiveSupport::Concern diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb index c8e20c0ab81..dfbe413a878 100644 --- a/app/models/concerns/has_variable.rb +++ b/app/models/concerns/has_variable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module HasVariable extend ActiveSupport::Concern diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb index 03793e8bcbb..2b074c1921c 100644 --- a/app/models/concerns/ignorable_column.rb +++ b/app/models/concerns/ignorable_column.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Module that can be included into a model to make it easier to ignore database # columns. # diff --git a/app/models/concerns/iid_routes.rb b/app/models/concerns/iid_routes.rb index 246748cf52c..b7f99e845ca 100644 --- a/app/models/concerns/iid_routes.rb +++ b/app/models/concerns/iid_routes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module IidRoutes ## # This automagically enforces all related routes to use `iid` instead of `id` diff --git a/app/models/concerns/importable.rb b/app/models/concerns/importable.rb index c9331eaf4cc..4d2707b08ab 100644 --- a/app/models/concerns/importable.rb +++ b/app/models/concerns/importable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Importable extend ActiveSupport::Concern diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b93c1145f82..e8072145551 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Issuable concern # # Contains common functionality shared between Issues and MergeRequests @@ -154,7 +156,7 @@ module Issuable end # Break ties with the ID column for pagination - sorted.order(id: :desc) + sorted.with_order_id_desc end def order_due_date_and_labels_priority(excluded_labels: []) @@ -243,6 +245,12 @@ module Issuable opened? end + def overdue? + return false unless respond_to?(:due_date) + + due_date.try(:past?) || false + end + def user_notes_count if notes.loaded? # Use the in-memory association to select and count to avoid hitting the db diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb index 935e9d10133..a2233eb2997 100644 --- a/app/models/concerns/loaded_in_group_list.rb +++ b/app/models/concerns/loaded_in_group_list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module LoadedInGroupList extend ActiveSupport::Concern diff --git a/app/models/concerns/manual_inverse_association.rb b/app/models/concerns/manual_inverse_association.rb index 0fca8feaf89..d0d781dc15f 100644 --- a/app/models/concerns/manual_inverse_association.rb +++ b/app/models/concerns/manual_inverse_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ManualInverseAssociation extend ActiveSupport::Concern diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index c013e5a708f..7e7eccb1c27 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Mentionable concern # # Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index 2d86a70c395..f6fd28bac33 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Mentionable module ReferenceRegexes def self.reference_pattern(link_patterns, issue_pattern) diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 967fd9c5eea..e44a069b730 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Milestoneish def closed_items_count(user) memoize_per_user(user, :closed_items_count) do diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 510b8868462..aad19329be1 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Contains functionality shared between `DiffNote` and `LegacyDiffNote`. module NoteOnDiff extend ActiveSupport::Concern diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 86f28f30032..ce778eae271 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Noteable # Names of all implementers of `Noteable` that support resolvable notes. RESOLVABLE_TYPES = %w(MergeRequest).freeze diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 01b1ef9f82c..1f6c42f3b3a 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Participable concern # # Contains functionality related to objects that can have participants, such as diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb index bc4fbd19a02..06c300c2e41 100644 --- a/app/models/concerns/presentable.rb +++ b/app/models/concerns/presentable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Presentable extend ActiveSupport::Concern diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 1f7d78a2efe..f268a842db4 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Makes api V4 compatible with old project features permissions methods # # After migrating issues_enabled merge_requests_enabled builds_enabled snippets_enabled and wiki_enabled diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index 9c36f633395..a29e80fe0c1 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PrometheusAdapter extend ActiveSupport::Concern diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb index e62f42e8e70..744f7f48dc8 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProtectedBranchAccess extend ActiveSupport::Concern diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index dbe8d31de37..e62e680af6e 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProtectedRef extend ActiveSupport::Concern diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 5ff7b41b82b..efa666fb3f2 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProtectedRefAccess extend ActiveSupport::Concern diff --git a/app/models/concerns/protected_tag_access.rb b/app/models/concerns/protected_tag_access.rb index ee65de24dd8..04bd54d6b1c 100644 --- a/app/models/concerns/protected_tag_access.rb +++ b/app/models/concerns/protected_tag_access.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProtectedTagAccess extend ActiveSupport::Concern diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 9155d82d567..d3572875fb3 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # The ReactiveCaching concern is used to fetch some data in the background and # store it in the Rails cache, keeping it up-to-date for as long as it is being # requested. If the data hasn't been requested for +reactive_cache_lifetime+, @@ -42,6 +44,8 @@ module ReactiveCaching extend ActiveSupport::Concern + InvalidateReactiveCache = Class.new(StandardError) + included do class_attribute :reactive_cache_lease_timeout @@ -63,15 +67,19 @@ module ReactiveCaching end def with_reactive_cache(*args, &blk) - bootstrap = !within_reactive_cache_lifetime?(*args) - Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) + unless within_reactive_cache_lifetime?(*args) + refresh_reactive_cache!(*args) + return nil + end - if bootstrap - ReactiveCachingWorker.perform_async(self.class, id, *args) - nil - else + keep_alive_reactive_cache!(*args) + + begin data = Rails.cache.read(full_reactive_cache_key(*args)) yield data if data.present? + rescue InvalidateReactiveCache + refresh_reactive_cache!(*args) + nil end end @@ -96,6 +104,16 @@ module ReactiveCaching private + def refresh_reactive_cache!(*args) + clear_reactive_cache!(*args) + keep_alive_reactive_cache!(*args) + ReactiveCachingWorker.perform_async(self.class, id, *args) + end + + def keep_alive_reactive_cache!(*args) + Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) + end + def full_reactive_cache_key(*qualifiers) prefix = self.class.reactive_cache_key prefix = prefix.call(self) if prefix.respond_to?(:call) diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb index 713246039c1..af69da24994 100644 --- a/app/models/concerns/reactive_service.rb +++ b/app/models/concerns/reactive_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ReactiveService extend ActiveSupport::Concern diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb index 3bdc1330d23..69554f18ea2 100644 --- a/app/models/concerns/redis_cacheable.rb +++ b/app/models/concerns/redis_cacheable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RedisCacheable extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index b782e85717e..468eaf68883 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Referable concern # # Contains functionality related to making a model referable in Markdown, such diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index afacdb8cb12..85229cded5d 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RelativePositioning extend ActiveSupport::Concern diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index 7c236369793..c0490af2453 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ResolvableDiscussion extend ActiveSupport::Concern include ::Gitlab::Utils::StrongMemoize diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb index 4a0f8b92b3a..f47e20229f1 100644 --- a/app/models/concerns/resolvable_note.rb +++ b/app/models/concerns/resolvable_note.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ResolvableNote extend ActiveSupport::Concern diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index cb91f8fbac8..b9ffc64e4a9 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Store object full path in separate table for easy lookup and uniq validation # Object must have name and path db fields and respond to parent and parent_changed? methods. module Routable diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb index 7af0fdbd618..39306179eb8 100644 --- a/app/models/concerns/select_for_project_authorization.rb +++ b/app/models/concerns/select_for_project_authorization.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SelectForProjectAuthorization extend ActiveSupport::Concern diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index 3796737427a..c322c356db2 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ShaAttribute extend ActiveSupport::Concern diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index cb76ae971d4..501bd1bb83c 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Sortable concern # # Set default scope for ordering objects @@ -6,6 +8,7 @@ module Sortable extend ActiveSupport::Concern included do + scope :with_order_id_desc, -> { order(id: :desc) } scope :order_id_desc, -> { reorder(id: :desc) } scope :order_id_asc, -> { reorder(id: :asc) } scope :order_created_desc, -> { reorder(created_at: :desc) } diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 5e4274619c4..c6e3dc385fe 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Spammable extend ActiveSupport::Concern diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index f5225cd81ed..3b745657a9e 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Storage module LegacyNamespace extend ActiveSupport::Concern diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb index ff82cb0ffa9..a377fa1e5de 100644 --- a/app/models/concerns/storage/legacy_project_wiki.rb +++ b/app/models/concerns/storage/legacy_project_wiki.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Storage module LegacyProjectWiki extend ActiveSupport::Concern diff --git a/app/models/concerns/storage/legacy_repository.rb b/app/models/concerns/storage/legacy_repository.rb index 593749bf019..eb93d0fc7f1 100644 --- a/app/models/concerns/storage/legacy_repository.rb +++ b/app/models/concerns/storage/legacy_repository.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Storage module LegacyRepository extend ActiveSupport::Concern diff --git a/app/models/concerns/strip_attribute.rb b/app/models/concerns/strip_attribute.rb index 8806ebe897a..344f677a3f3 100644 --- a/app/models/concerns/strip_attribute.rb +++ b/app/models/concerns/strip_attribute.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Strip Attribute module # # Contains functionality to clean attributes before validation diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index f478c8ede18..1d0a61364b0 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Subscribable concern # # Users can subscribe to these models. diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index ccd6f0e0a7d..603d4d62578 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'task_list' require 'task_list/filter' diff --git a/app/models/concerns/throttled_touch.rb b/app/models/concerns/throttled_touch.rb index ad0ff0f20d4..797c46f6cc5 100644 --- a/app/models/concerns/throttled_touch.rb +++ b/app/models/concerns/throttled_touch.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # ThrottledTouch can be used to throttle the number of updates triggered by # calling "touch" on an ActiveRecord model. module ThrottledTouch diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 0fc321c52bc..f61a0bbc65b 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == TimeTrackable concern # # Contains functionality related to objects that support time tracking. diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index ec3543f7053..522b65e4205 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TokenAuthenticatable extend ActiveSupport::Concern diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index ec0ed3b795a..f55ab2fcaf3 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TriggerableHooks AVAILABLE_TRIGGERS = { repository_update_hooks: :repository_update_events, diff --git a/app/models/concerns/uniquify.rb b/app/models/concerns/uniquify.rb index 549a76da20e..382e826ec58 100644 --- a/app/models/concerns/uniquify.rb +++ b/app/models/concerns/uniquify.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Uniquify # # Return a version of the given 'base' string that is unique diff --git a/app/models/concerns/updated_at_filterable.rb b/app/models/concerns/updated_at_filterable.rb index edb423b7828..1ab5ee9fbb9 100644 --- a/app/models/concerns/updated_at_filterable.rb +++ b/app/models/concerns/updated_at_filterable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module UpdatedAtFilterable extend ActiveSupport::Concern diff --git a/app/models/concerns/valid_attribute.rb b/app/models/concerns/valid_attribute.rb index 8c35cea8d58..251db9ce30b 100644 --- a/app/models/concerns/valid_attribute.rb +++ b/app/models/concerns/valid_attribute.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ValidAttribute extend ActiveSupport::Concern diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index 4245d083a49..e231af5368d 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Mounted uploaders are destroyed by carrierwave's after_commit # hook. This hook fetches upload location (local vs remote) from # Upload model. So it's neccessary to make sure that during that diff --git a/app/models/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb index e8f09dc9161..f9180bdd97b 100644 --- a/app/models/conversational_development_index/card.rb +++ b/app/models/conversational_development_index/card.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ConversationalDevelopmentIndex class Card attr_accessor :metric, :title, :description, :feature, :blog, :docs diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb index 6e1753c9f30..e78a734693c 100644 --- a/app/models/conversational_development_index/idea_to_production_step.rb +++ b/app/models/conversational_development_index/idea_to_production_step.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ConversationalDevelopmentIndex class IdeaToProductionStep attr_accessor :metric, :title, :features diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb index 0bee62f954f..c54537572d6 100644 --- a/app/models/conversational_development_index/metric.rb +++ b/app/models/conversational_development_index/metric.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ConversationalDevelopmentIndex class Metric < ActiveRecord::Base include Presentable diff --git a/app/models/diff_viewer/added.rb b/app/models/diff_viewer/added.rb index 1909e6ef9d8..70d13e3478c 100644 --- a/app/models/diff_viewer/added.rb +++ b/app/models/diff_viewer/added.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class Added < Base include Simple diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 0cbe714288d..1176861a827 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class Base PARTIAL_PATH_PREFIX = 'projects/diffs/viewers'.freeze diff --git a/app/models/diff_viewer/client_side.rb b/app/models/diff_viewer/client_side.rb index cf41d07f8eb..cc049e1ca49 100644 --- a/app/models/diff_viewer/client_side.rb +++ b/app/models/diff_viewer/client_side.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer module ClientSide extend ActiveSupport::Concern diff --git a/app/models/diff_viewer/deleted.rb b/app/models/diff_viewer/deleted.rb index 9c129bac694..78671084eeb 100644 --- a/app/models/diff_viewer/deleted.rb +++ b/app/models/diff_viewer/deleted.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class Deleted < Base include Simple diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb index 759d9a36ebb..c356c2ca50e 100644 --- a/app/models/diff_viewer/image.rb +++ b/app/models/diff_viewer/image.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class Image < Base include Rich diff --git a/app/models/diff_viewer/mode_changed.rb b/app/models/diff_viewer/mode_changed.rb index d487d996f8d..bd07d1e21b9 100644 --- a/app/models/diff_viewer/mode_changed.rb +++ b/app/models/diff_viewer/mode_changed.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class ModeChanged < Base include Simple diff --git a/app/models/diff_viewer/no_preview.rb b/app/models/diff_viewer/no_preview.rb index 5455fee4490..b0dd5fd151d 100644 --- a/app/models/diff_viewer/no_preview.rb +++ b/app/models/diff_viewer/no_preview.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class NoPreview < Base include Simple diff --git a/app/models/diff_viewer/not_diffable.rb b/app/models/diff_viewer/not_diffable.rb index 4f9638626ea..dc86599e722 100644 --- a/app/models/diff_viewer/not_diffable.rb +++ b/app/models/diff_viewer/not_diffable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class NotDiffable < Base include Simple diff --git a/app/models/diff_viewer/renamed.rb b/app/models/diff_viewer/renamed.rb index f1fbfd8c6d5..70ab9d4ca2c 100644 --- a/app/models/diff_viewer/renamed.rb +++ b/app/models/diff_viewer/renamed.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class Renamed < Base include Simple diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb index 3b0ca6e4cff..2faa1be6567 100644 --- a/app/models/diff_viewer/rich.rb +++ b/app/models/diff_viewer/rich.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer module Rich extend ActiveSupport::Concern diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb index aed1a0791b1..977204e6c97 100644 --- a/app/models/diff_viewer/server_side.rb +++ b/app/models/diff_viewer/server_side.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer module ServerSide extend ActiveSupport::Concern diff --git a/app/models/diff_viewer/simple.rb b/app/models/diff_viewer/simple.rb index 65750996ee4..8d28ca5239a 100644 --- a/app/models/diff_viewer/simple.rb +++ b/app/models/diff_viewer/simple.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer module Simple extend ActiveSupport::Concern diff --git a/app/models/diff_viewer/static.rb b/app/models/diff_viewer/static.rb index d761328b3f6..1278c22185c 100644 --- a/app/models/diff_viewer/static.rb +++ b/app/models/diff_viewer/static.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer module Static extend ActiveSupport::Concern diff --git a/app/models/diff_viewer/text.rb b/app/models/diff_viewer/text.rb index 98f4b2aea2a..d3d5055c402 100644 --- a/app/models/diff_viewer/text.rb +++ b/app/models/diff_viewer/text.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffViewer class Text < Base include Simple diff --git a/app/models/group.rb b/app/models/group.rb index cd548fc0061..106a1f4a94c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -41,6 +41,8 @@ class Group < Namespace has_many :boards has_many :badges, class_name: 'GroupBadge' + has_many :todos + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index ec072882cc9..18c387f0d34 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectHook < WebHook include TriggerableHooks diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index aef11514945..bda82a116a1 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ServiceHook < WebHook belongs_to :service validates :service, presence: true diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 6bef00f26ea..90b4588a325 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SystemHook < WebHook include TriggerableHooks diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index e353abdda9c..f18aadefa5c 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WebHook < ActiveRecord::Base include Sortable diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index 59a1f2aed69..2d9f7594e8c 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WebHookLog < ActiveRecord::Base belongs_to :web_hook diff --git a/app/models/issue.rb b/app/models/issue.rb index 0d135f54038..94cf12f3c2b 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -278,10 +278,6 @@ class Issue < ActiveRecord::Base user ? readable_by?(user) : publicly_visible? end - def overdue? - due_date.try(:past?) || false - end - def check_for_spam? project.public? && (title_changed? || description_changed?) end diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb index 012d545c440..0f5ee957ec9 100644 --- a/app/models/issue/metrics.rb +++ b/app/models/issue/metrics.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Issue::Metrics < ActiveRecord::Base belongs_to :issue diff --git a/app/models/list.rb b/app/models/list.rb index eabe3ffccbb..1a30acc83cf 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -4,7 +4,7 @@ class List < ActiveRecord::Base belongs_to :board belongs_to :label - enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 } + enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 } validates :board, :list_type, presence: true validates :label, :position, presence: true, if: :label? @@ -27,11 +27,11 @@ class List < ActiveRecord::Base end def destroyable? - label? + self.class.destroyable_types.include?(list_type&.to_sym) end def movable? - label? + self.class.movable_types.include?(list_type&.to_sym) end def title diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 5da739f9618..fc49ee7ac8c 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupMember < Member SOURCE_TYPE = 'Namespace'.freeze diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 4f27d0aeaf8..0154fe5aeba 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectMember < Member SOURCE_TYPE = 'Project'.freeze diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 6de44751f1b..396647a14ae 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -16,8 +16,8 @@ class MergeRequest < ActiveRecord::Base include ReactiveCaching self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } - self.reactive_cache_refresh_interval = 1.hour - self.reactive_cache_lifetime = 1.hour + self.reactive_cache_refresh_interval = 10.minutes + self.reactive_cache_lifetime = 10.minutes ignore_column :locked_at, :ref_fetched, @@ -60,6 +60,8 @@ class MergeRequest < ActiveRecord::Base class_name: 'MergeRequestsClosingIssues', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue + belongs_to :assignee, class_name: "User" serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize @@ -763,8 +765,9 @@ class MergeRequest < ActiveRecord::Base # Calculating this information for a number of merge requests requires # running `ReferenceExtractor` on each of them separately. # This optimization does not apply to issues from external sources. - def cache_merge_request_closes_issues!(current_user) + def cache_merge_request_closes_issues!(current_user = self.author) return unless project.issues_enabled? + return if closed? || merged? transaction do self.merge_requests_closing_issues.delete_all @@ -777,6 +780,18 @@ class MergeRequest < ActiveRecord::Base end end + def visible_closing_issues_for(current_user = self.author) + strong_memoize(:visible_closing_issues_for) do + if self.target_project.has_external_issue_tracker? + closes_issues(current_user) + else + cached_closes_issues.select do |issue| + Ability.allowed?(current_user, :read_issue, issue) + end + end + end + end + # Return the set of issues that will be closed if this merge request is accepted. def closes_issues(current_user = self.author) if target_branch == project.default_branch @@ -796,7 +811,7 @@ class MergeRequest < ActiveRecord::Base ext = Gitlab::ReferenceExtractor.new(project, current_user) ext.analyze("#{title}\n#{description}") - ext.issues - closes_issues(current_user) + ext.issues - visible_closing_issues_for(current_user) end def target_project_path @@ -844,7 +859,7 @@ class MergeRequest < ActiveRecord::Base end def merge_commit_message(include_description: false) - closes_issues_references = closes_issues.map do |issue| + closes_issues_references = visible_closing_issues_for.map do |issue| issue.to_reference(target_project) end @@ -1026,16 +1041,21 @@ class MergeRequest < ActiveRecord::Base return { status: :error, status_reason: 'This merge request does not have test reports' } end - with_reactive_cache( - :compare_test_results, - base_pipeline&.iid, - actual_head_pipeline.iid) { |data| data } || { status: :parsing } + with_reactive_cache(:compare_test_results) do |data| + unless Ci::CompareTestReportsService.new(project) + .latest?(base_pipeline, actual_head_pipeline, data) + raise InvalidateReactiveCache + end + + data + end || { status: :parsing } end def calculate_reactive_cache(identifier, *args) case identifier.to_sym when :compare_test_results - Ci::CompareTestReportsService.new(project).execute(*args) + Ci::CompareTestReportsService.new(project).execute( + base_pipeline, actual_head_pipeline) else raise NotImplementedError, "Unknown identifier: #{identifier}" end @@ -1074,23 +1094,29 @@ class MergeRequest < ActiveRecord::Base def can_be_reverted?(current_user) return false unless merge_commit + return false unless merged_at - merged_at = metrics&.merged_at - notes_association = notes_with_associations - - if merged_at - # It is not guaranteed that Note#created_at will be strictly later than - # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this - # comparison, as will a HA environment if clocks are not *precisely* - # synchronized. Add a minute's leeway to compensate for both possibilities - cutoff = merged_at - 1.minute + # It is not guaranteed that Note#created_at will be strictly later than + # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this + # comparison, as will a HA environment if clocks are not *precisely* + # synchronized. Add a minute's leeway to compensate for both possibilities + cutoff = merged_at - 1.minute - notes_association = notes_association.where('created_at >= ?', cutoff) - end + notes_association = notes_with_associations.where('created_at >= ?', cutoff) !merge_commit.has_been_reverted?(current_user, notes_association) end + def merged_at + strong_memoize(:merged_at) do + next unless merged? + + metrics&.merged_at || + merge_event&.created_at || + notes.system.reorder(nil).find_by(note: 'merged')&.created_at + end + end + def can_be_cherry_picked? merge_commit.present? end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 9e660eccd86..65e94a97b0a 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MergeRequest::Metrics < ActiveRecord::Base belongs_to :merge_request belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id diff --git a/app/models/milestone.rb b/app/models/milestone.rb index f2b2d291da9..cb1def1b422 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -146,22 +146,25 @@ class Milestone < ActiveRecord::Base end def self.sort_by_attribute(method) - case method.to_s - when 'due_date_asc' - reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) - when 'due_date_desc' - reorder(Gitlab::Database.nulls_last_order('due_date', 'DESC')) - when 'name_asc' - reorder(Arel::Nodes::Ascending.new(arel_table[:title].lower)) - when 'name_desc' - reorder(Arel::Nodes::Descending.new(arel_table[:title].lower)) - when 'start_date_asc' - reorder(Gitlab::Database.nulls_last_order('start_date', 'ASC')) - when 'start_date_desc' - reorder(Gitlab::Database.nulls_last_order('start_date', 'DESC')) - else - order_by(method) - end + sorted = + case method.to_s + when 'due_date_asc' + reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) + when 'due_date_desc' + reorder(Gitlab::Database.nulls_last_order('due_date', 'DESC')) + when 'name_asc' + reorder(Arel::Nodes::Ascending.new(arel_table[:title].lower)) + when 'name_desc' + reorder(Arel::Nodes::Descending.new(arel_table[:title].lower)) + when 'start_date_asc' + reorder(Gitlab::Database.nulls_last_order('start_date', 'ASC')) + when 'start_date_desc' + reorder(Gitlab::Database.nulls_last_order('start_date', 'DESC')) + else + order_by(method) + end + + sorted.with_order_id_desc end ## diff --git a/app/models/network/commit.rb b/app/models/network/commit.rb index d667948deae..6c5a4c56377 100644 --- a/app/models/network/commit.rb +++ b/app/models/network/commit.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Network class Commit include ActionView::Helpers::TagHelper diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 1e0d1f9edcb..1431dfefc55 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Network class Graph attr_reader :days, :commits, :map, :notes, :repo diff --git a/app/models/note.rb b/app/models/note.rb index 969d34ae09a..2e343b8f9f8 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -231,6 +231,10 @@ class Note < ActiveRecord::Base !for_personal_snippet? end + def for_issuable? + for_issue? || for_merge_request? + end + def skip_project_check? !for_project_noteable? end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb new file mode 100644 index 00000000000..70c7432e6b5 --- /dev/null +++ b/app/models/postgresql/replication_slot.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Postgresql + class ReplicationSlot < ActiveRecord::Base + self.table_name = 'pg_replication_slots' + + # Returns true if the lag observed across all replication slots exceeds a + # given threshold. + # + # max - The maximum replication lag size, in bytes. Based on GitLab.com + # statistics it takes between 1 and 5 seconds to replicate around + # 100 MB of data. + def self.lag_too_great?(max = 100.megabytes) + lag_function = "#{Gitlab::Database.pg_wal_lsn_diff}" \ + "(#{Gitlab::Database.pg_current_wal_insert_lsn}(), restart_lsn)::bigint" + + # We force the use of a transaction here so the query always goes to the + # primary, even when using the EE DB load balancer. + sizes = transaction { pluck(lag_function) } + too_great = sizes.count { |size| size >= max } + + # If too many replicas are falling behind too much, the availability of a + # GitLab instance might suffer. To prevent this from happening we require + # at least 1 replica to have data recent enough. + if sizes.any? && too_great.positive? + (sizes.length - too_great) <= 1 + else + false + end + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 02e956911a9..36089995ed3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -507,6 +507,10 @@ class Project < ActiveRecord::Base end end + def has_auto_devops_implicitly_enabled? + auto_devops&.enabled.nil? && Gitlab::CurrentSettings.auto_devops_enabled? + end + def has_auto_devops_implicitly_disabled? auto_devops&.enabled.nil? && !Gitlab::CurrentSettings.auto_devops_enabled? end @@ -654,6 +658,8 @@ class Project < ActiveRecord::Base project_import_data.credentials ||= {} project_import_data.credentials = project_import_data.credentials.merge(credentials) end + + project_import_data end def import? @@ -1320,14 +1326,6 @@ class Project < ActiveRecord::Base :visibility_level end - def archive! - update_attribute(:archived, true) - end - - def unarchive! - update_attribute(:archived, false) - end - def change_head(branch) if repository.branch_exists?(branch) repository.before_change_head diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 412d62388f0..82d438d5378 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -8,6 +8,10 @@ class JiraService < IssueTrackerService validates :username, presence: true, if: :activated? validates :password, presence: true, if: :activated? + validates :jira_issue_transition_id, + format: { with: Gitlab::Regex.jira_transition_id_regex, message: "transition ids can have only numbers which can be split with , or ;" }, + allow_blank: true + prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description before_update :reset_password @@ -91,7 +95,7 @@ class JiraService < IssueTrackerService { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' }, { type: 'text', name: 'username', placeholder: '', required: true }, { type: 'password', name: 'password', placeholder: '', required: true }, - { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID', placeholder: '' } + { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID(s)', placeholder: 'Use , or ; to separate multiple transition IDs' } ] end @@ -191,8 +195,18 @@ class JiraService < IssueTrackerService end end + # jira_issue_transition_id can have multiple values split by , or ; + # the issue is transitioned at the order given by the user + # if any transition fails it will log the error message and stop the transition sequence def transition_issue(issue) - issue.transitions.build.save(transition: { id: jira_issue_transition_id }) + jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).each do |transition_id| + begin + issue.transitions.build.save!(transition: { id: transition_id }) + rescue => error + Rails.logger.info "#{self.class.name} Issue Transition failed message ERROR: #{client_url} - #{error.message}" + return false + end + end end def add_issue_solved_comment(issue, commit_id, commit_url) diff --git a/app/models/todo.rb b/app/models/todo.rb index 5f5c2f9073d..48d92ad04b3 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -24,15 +24,18 @@ class Todo < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :note belongs_to :project + belongs_to :group belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user delegate :name, :email, to: :author, prefix: true, allow_nil: true - validates :action, :project, :target_type, :user, presence: true + validates :action, :target_type, :user, presence: true validates :author, presence: true validates :target_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? + validates :project, presence: true, unless: :group_id + validates :group, presence: true, unless: :project_id scope :pending, -> { with_state(:pending) } scope :done, -> { with_state(:done) } @@ -46,7 +49,7 @@ class Todo < ActiveRecord::Base state :done end - after_save :keep_around_commit + after_save :keep_around_commit, if: :commit_id class << self # Priority sorting isn't displayed in the dropdown, because we don't show @@ -81,6 +84,10 @@ class Todo < ActiveRecord::Base end end + def parent + project + end + def unmergeable? action == UNMERGEABLE end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index ffa238a63d5..8c4eac3c31d 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -206,7 +206,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def closing_issues - @closing_issues ||= closes_issues(current_user) + @closing_issues ||= visible_closing_issues_for(current_user) end def pipeline diff --git a/app/services/ci/compare_test_reports_service.rb b/app/services/ci/compare_test_reports_service.rb index 7a112211d94..ec25e934a27 100644 --- a/app/services/ci/compare_test_reports_service.rb +++ b/app/services/ci/compare_test_reports_service.rb @@ -2,23 +2,36 @@ module Ci class CompareTestReportsService < ::BaseService - def execute(base_pipeline_iid, head_pipeline_iid) - base_pipeline = project.pipelines.find_by_iid(base_pipeline_iid) if base_pipeline_iid - head_pipeline = project.pipelines.find_by_iid(head_pipeline_iid) + def execute(base_pipeline, head_pipeline) + comparer = Gitlab::Ci::Reports::TestReportsComparer + .new(base_pipeline&.test_reports, head_pipeline.test_reports) - begin - comparer = Gitlab::Ci::Reports::TestReportsComparer - .new(base_pipeline&.test_reports, head_pipeline.test_reports) + { + status: :parsed, + key: key(base_pipeline, head_pipeline), + data: TestReportsComparerSerializer + .new(project: project) + .represent(comparer).as_json + } + rescue => e + { + status: :error, + key: key(base_pipeline, head_pipeline), + status_reason: e.message + } + end + + def latest?(base_pipeline, head_pipeline, data) + data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + end + + private - { - status: :parsed, - data: TestReportsComparerSerializer - .new(project: project) - .represent(comparer).as_json - } - rescue => e - { status: :error, status_reason: e.message } - end + def key(base_pipeline, head_pipeline) + [ + base_pipeline&.id, base_pipeline&.updated_at, + head_pipeline&.id, head_pipeline&.updated_at + ] end end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 436a6b18cb1..fe47aa2f140 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -14,7 +14,9 @@ module Groups group.assign_attributes(params) begin - group.save + after_update if group.save + + true rescue Gitlab::UpdatePathError => e group.errors.add(:base, e.message) @@ -24,6 +26,13 @@ module Groups private + def after_update + if group.previous_changes.include?(:visibility_level) && group.private? + # don't enqueue immediately to prevent todos removal in case of a mistake + TodosDestroyer::GroupPrivateWorker.perform_in(1.hour, group.id) + end + end + def reject_parent_id! params.except!(:parent_id) end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 3d2aea4e9b6..f26e3bee06f 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -25,7 +25,7 @@ module MergeRequests def close_issues(merge_request) return unless merge_request.target_branch == project.default_branch - closed_issues = merge_request.closes_issues(current_user) + closed_issues = merge_request.visible_closing_issues_for(current_user) closed_issues.each do |issue| if can?(current_user, :update_issue, issue) diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index f2fc13ad028..f6cbe769ef4 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -14,6 +14,7 @@ module MergeRequests merge_request.mark_as_unchecked invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches + merge_request.cache_merge_request_closes_issues!(current_user) end merge_request diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 0bcd53c76a9..0df61ad3bce 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -262,15 +262,15 @@ class TodoService end end - def create_mention_todos(project, target, author, note = nil, skip_users = []) + def create_mention_todos(parent, target, author, note = nil, skip_users = []) # Create Todos for directly addressed users - directly_addressed_users = filter_directly_addressed_users(project, note || target, author, skip_users) - attributes = attributes_for_todo(project, target, author, Todo::DIRECTLY_ADDRESSED, note) + directly_addressed_users = filter_directly_addressed_users(parent, note || target, author, skip_users) + attributes = attributes_for_todo(parent, target, author, Todo::DIRECTLY_ADDRESSED, note) create_todos(directly_addressed_users, attributes) # Create Todos for mentioned users - mentioned_users = filter_mentioned_users(project, note || target, author, skip_users) - attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note) + mentioned_users = filter_mentioned_users(parent, note || target, author, skip_users) + attributes = attributes_for_todo(parent, target, author, Todo::MENTIONED, note) create_todos(mentioned_users, attributes) end @@ -301,36 +301,36 @@ class TodoService def attributes_for_todo(project, target, author, action, note = nil) attributes_for_target(target).merge!( - project_id: project.id, + project_id: project&.id, author_id: author.id, action: action, note: note ) end - def filter_todo_users(users, project, target) - reject_users_without_access(users, project, target).uniq + def filter_todo_users(users, parent, target) + reject_users_without_access(users, parent, target).uniq end - def filter_mentioned_users(project, target, author, skip_users = []) + def filter_mentioned_users(parent, target, author, skip_users = []) mentioned_users = target.mentioned_users(author) - skip_users - filter_todo_users(mentioned_users, project, target) + filter_todo_users(mentioned_users, parent, target) end - def filter_directly_addressed_users(project, target, author, skip_users = []) + def filter_directly_addressed_users(parent, target, author, skip_users = []) directly_addressed_users = target.directly_addressed_users(author) - skip_users - filter_todo_users(directly_addressed_users, project, target) + filter_todo_users(directly_addressed_users, parent, target) end - def reject_users_without_access(users, project, target) - if target.is_a?(Note) && (target.for_issue? || target.for_merge_request?) + def reject_users_without_access(users, parent, target) + if target.is_a?(Note) && target.for_issuable? target = target.noteable end if target.is_a?(Issuable) select_users(users, :"read_#{target.to_ability_name}", target) else - select_users(users, :read_project, project) + select_users(users, :read_project, parent) end end diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb index 2ff9f94b718..045f5ecaae7 100644 --- a/app/services/todos/destroy/entity_leave_service.rb +++ b/app/services/todos/destroy/entity_leave_service.rb @@ -3,55 +3,97 @@ module Todos class EntityLeaveService < ::Todos::Destroy::BaseService extend ::Gitlab::Utils::Override - attr_reader :user_id, :entity + attr_reader :user, :entity def initialize(user_id, entity_id, entity_type) unless %w(Group Project).include?(entity_type) raise ArgumentError.new("#{entity_type} is not an entity user can leave") end - @user_id = user_id + @user = User.find_by(id: user_id) @entity = entity_type.constantize.find_by(id: entity_id) end - private + def execute + return unless entity && user + + # if at least reporter, all entities including confidential issues can be accessed + return if user_has_reporter_access? + + remove_confidential_issue_todos - override :todos - def todos if entity.private? - Todo.where(project_id: project_ids, user_id: user_id) + remove_project_todos + remove_group_todos else - project_ids.each do |project_id| - TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user_id) - end + enqueue_private_features_worker + end + end + + private - Todo.where( - target_id: confidential_issues.select(:id), target_type: Issue, user_id: user_id - ) + def enqueue_private_features_worker + project_ids.each do |project_id| + TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id) end end + def remove_confidential_issue_todos + Todo.where( + target_id: confidential_issues.select(:id), target_type: Issue, user_id: user.id + ).delete_all + end + + def remove_project_todos + Todo.where(project_id: non_authorized_projects, user_id: user.id).delete_all + end + + def remove_group_todos + Todo.where(group_id: non_authorized_groups, user_id: user.id).delete_all + end + override :project_ids def project_ids - case entity - when Project - [entity.id] - when Namespace - Project.select(:id).where(namespace_id: entity.self_and_descendants.select(:id)) - end + condition = case entity + when Project + { id: entity.id } + when Namespace + { namespace_id: non_member_groups } + end + + Project.where(condition).select(:id) end - override :todos_to_remove? - def todos_to_remove? - # if an entity is provided we want to check always at least private features - !!entity + def non_authorized_projects + project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id)) + end + + def non_authorized_groups + return [] unless entity.is_a?(Namespace) + + entity.self_and_descendants.select(:id) + .where('id NOT IN (?)', GroupsFinder.new(user).execute.select(:id)) + end + + def non_member_groups + entity.self_and_descendants.select(:id) + .where('id NOT IN (?)', user.membership_groups.select(:id)) + end + + def user_has_reporter_access? + return unless entity.is_a?(Namespace) + + entity.member?(User.find(user.id), Gitlab::Access::REPORTER) end def confidential_issues - assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user_id) + assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user.id) + authorized_reporter_projects = user + .authorized_projects(Gitlab::Access::REPORTER).select(:id) Issue.where(project_id: project_ids, confidential: true) - .where('author_id != ?', user_id) + .where('project_id NOT IN(?)', authorized_reporter_projects) + .where('author_id != ?', user.id) .where('id NOT IN (?)', assigned_ids) end end diff --git a/app/services/todos/destroy/group_private_service.rb b/app/services/todos/destroy/group_private_service.rb new file mode 100644 index 00000000000..d13fa7a6516 --- /dev/null +++ b/app/services/todos/destroy/group_private_service.rb @@ -0,0 +1,30 @@ +module Todos + module Destroy + class GroupPrivateService < ::Todos::Destroy::BaseService + extend ::Gitlab::Utils::Override + + attr_reader :group + + def initialize(group_id) + @group = Group.find_by(id: group_id) + end + + private + + override :todos + def todos + Todo.where(group_id: group.id) + end + + override :authorized_users + def authorized_users + group.direct_and_indirect_users.select(:id) + end + + override :todos_to_remove? + def todos_to_remove? + group&.private? + end + end + end +end diff --git a/app/services/todos/destroy/project_private_service.rb b/app/services/todos/destroy/project_private_service.rb index 171933e7cbc..315a0c33398 100644 --- a/app/services/todos/destroy/project_private_service.rb +++ b/app/services/todos/destroy/project_private_service.rb @@ -13,7 +13,7 @@ module Todos override :todos def todos - Todo.where(project_id: project_ids) + Todo.where(project_id: project.id) end override :project_ids diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 472616b1315..5037017e38a 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -3,13 +3,15 @@ %fieldset .form-group - .form-check - = f.check_box :auto_devops_enabled, class: 'form-check-input' - = f.label :auto_devops_enabled, class: 'form-check-label' do - Enabled Auto DevOps for projects by default - .form-text.text-muted - It will automatically build, test, and deploy applications based on a predefined CI/CD configuration - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') + .card.auto-devops-card + .card-body + .form-check + = f.check_box :auto_devops_enabled, class: 'form-check-input' + = f.label :auto_devops_enabled, class: 'form-check-label' do + Default to Auto DevOps pipeline for all projects + .form-text.text-muted + = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') + = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .form-group = f.label :auto_devops_domain, class: 'label-bold' = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 1c8801566d4..258d50ad676 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -338,4 +338,27 @@ = render_if_exists 'admin/application_settings/custom_templates_form', expanded: expanded +%section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Web IDE') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Manage Web IDE features') + .settings-content + = form_for @application_setting, url: admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input' + = f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do + = s_('IDE|Client side evaluation') + %span.form-text.text-muted + = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation.') + + = f.submit _('Save changes'), class: "btn btn-success" + = render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index c3ea2352898..dbb7224f5f9 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,7 +1,7 @@ -%li{ id: dom_id(label) } - .label-row - = render_colored_label(label, tooltip: false) - = markdown_field(label, :description) - .float-right - = link_to _('Edit'), edit_admin_label_path(label), class: 'btn btn-sm' - = link_to _('Delete'), admin_label_path(label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Delete this label? Are you sure?"} +%li.label-list-item{ id: dom_id(label) } + = render "shared/label_row", label: label + .label-actions-list + = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do + = sprite_icon('pencil') + = link_to admin_label_path(label), class: 'btn btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do + = sprite_icon('remove') diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index d3e5247447a..f1b8658f84e 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -7,10 +7,11 @@ = _('Labels') %hr -.labels +.labels.labels-container.admin-labels - if @labels.present? - %ul.bordered-list.manage-labels-list + %ul.manage-labels-list = render @labels + = paginate @labels, theme: 'gitlab' - else .card.bg-light diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index fdaacc098e0..50296a2afe7 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -20,7 +20,7 @@ = link_to(admin_namespace_project_path(project.namespace, project)) do .dash-project-avatar .avatar-container.s40 - = project_icon(project, alt: '', class: 'avatar project-avatar s40') + = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) %span.project-full-name %span.namespace-name - if project.namespace diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index d5a9cc646a6..8b3974d97f8 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -30,27 +30,33 @@ .todos-filters .row-content-block.second-block - = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form' do - .filter-item.inline - - if params[:project_id].present? - = hidden_field_tag(:project_id, params[:project_id]) - = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', - placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } }) - .filter-item.inline - - if params[:author_id].present? - = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', - placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) - .filter-item.inline - - if params[:type].present? - = hidden_field_tag(:type, params[:type]) - = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', - data: { data: todo_types_options, default_label: 'Type' } }) - .filter-item.inline.actions-filter - - if params[:action_id].present? - = hidden_field_tag(:action_id, params[:action_id]) - = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', - data: { data: todo_actions_options, default_label: 'Action' } }) + = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do + .filter-categories.flex-fill + .filter-item.inline + - if params[:group_id].present? + = hidden_field_tag(:group_id, params[:group_id]) + = dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', + placeholder: 'Search groups', data: { data: todo_group_options, default_label: 'Group', display: 'static' } }) + .filter-item.inline + - if params[:project_id].present? + = hidden_field_tag(:project_id, params[:project_id]) + = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } }) + .filter-item.inline + - if params[:author_id].present? + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', + placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) + .filter-item.inline + - if params[:type].present? + = hidden_field_tag(:type, params[:type]) + = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', + data: { data: todo_types_options, default_label: 'Type' } }) + .filter-item.inline.actions-filter + - if params[:action_id].present? + = hidden_field_tag(:action_id, params[:action_id]) + = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', + data: { data: todo_actions_options, default_label: 'Action' } }) .filter-item.sort-filter .dropdown %button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', 'data-toggle' => 'dropdown' } diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index ca62a59d909..74791b81ccd 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title - link_to_client = link_to(@pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer') - = _("Authorize %{link_to_client} to use your account?") + = _("Authorize %{link_to_client} to use your account?").html_safe % { link_to_client: link_to_client } .modal-body - if current_user.admin? diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index ab8263533be..0e225fe33a5 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -1,4 +1,5 @@ = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + %input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' } = form_errors(@group) %fieldset diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index f1f67af1d1e..ffce2d4b14f 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -1,4 +1,5 @@ = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + %input{ type: 'hidden', name: 'update_section', value: 'js-permissions-settings' } = form_errors(@group) %fieldset diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml index d29dda43c89..4cae9c51acc 100644 --- a/app/views/ide/index.html.haml +++ b/app/views/ide/index.html.haml @@ -8,7 +8,10 @@ "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), - "ci-help-page-path" => help_page_path('ci/quick_start/README'), } } + "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), + "ci-help-page-path" => help_page_path('ci/quick_start/README'), + "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), + "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } } .text-center = icon('spinner spin 2x') %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml new file mode 100644 index 00000000000..ac86be8fa7a --- /dev/null +++ b/app/views/import/bitbucket_server/new.html.haml @@ -0,0 +1,26 @@ +- title = _('Bitbucket Server Import') +- page_title title +- breadcrumb_title title +- header_title "Projects", root_path + +%h3.page-title + = icon 'bitbucket-square', text: _('Import repositories from Bitbucket Server') + +%p + = _('Enter in your Bitbucket Server URL and personal access token below') + += form_tag configure_import_bitbucket_server_path, method: :post do + .form-group.row + = label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :bitbucket_server_url, '', class: 'form-control append-right-8', placeholder: _('https://your-bitbucket-server'), size: 40 + .form-group.row + = label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :bitbucket_username, '', class: 'form-control append-right-8', placeholder: _('username'), size: 40 + .form-group.row + = label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2' + .col-md-4 + = password_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40 + .form-actions + = submit_tag _('List your Bitbucket Server repositories'), class: 'btn btn-success' diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml new file mode 100644 index 00000000000..3d05a5e696f --- /dev/null +++ b/app/views/import/bitbucket_server/status.html.haml @@ -0,0 +1,87 @@ +- page_title 'Bitbucket Server import' +- header_title 'Projects', root_path + +%h3.page-title + %i.fa.fa-bitbucket-square + = _('Import projects from Bitbucket Server') + +- if @repos.any? + %p.light + = _('Select projects you want to import.') + .btn-group + - if @incompatible_repos.any? + = button_tag class: 'btn btn-import btn-success js-import-all' do + = _('Import all compatible projects') + = icon('spinner spin', class: 'loading-icon') + - else + = button_tag class: 'btn btn-import btn-success js-import-all' do + = _('Import all projects') + = icon('spinner spin', class: 'loading-icon') + .btn-group + = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post) + +.table-responsive.prepend-top-10 + %table.table.import-jobs + %colgroup.import-jobs-from-col + %colgroup.import-jobs-to-col + %colgroup.import-jobs-status-col + %thead + %tr + %th= _('From Bitbucket Server') + %th= _('To GitLab') + %th= _(' Status') + %tbody + - @already_added_projects.each do |project| + %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } + %td + = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer' + %td + = link_to project.full_path, [project.namespace.becomes(Namespace), project] + %td.job-status + - if project.import_status == 'finished' + = icon('check', text: 'Done') + - elsif project.import_status == 'started' + = icon('spin', text: 'started') + - else + = project.human_import_status_name + + - @repos.each do |repo| + %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } } + %td + = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer' + %td.import-target + %fieldset.row + .input-group + .project-path.input-group-prepend + - if current_user.can_select_namespace? + - selected = params[:namespace_id] || :extra_group + - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.project_key, path: repo.project_key) } : {} + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } + - else + = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true + %span.input-group-prepend + .input-group-text / + = text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true + %td.import-actions.job-status + = button_tag class: 'btn btn-import js-add-to-import' do + Import + = icon('spinner spin', class: 'loading-icon') + - @incompatible_repos.each do |repo| + %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" } + %td + = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer' + %td.import-target + %td.import-actions-job-status + = label_tag 'Incompatible Project', nil, class: 'label badge-danger' + +- if @incompatible_repos.any? + %p + One or more of your Bitbucket Server projects cannot be imported into GitLab + directly because they use Subversion or Mercurial for version control, + rather than Git. Please convert + = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview' + and go through the + = link_to 'import flow', status_import_bitbucket_server_path + again. + +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } } diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 556ad8cf306..9a7a67cfa83 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -6,21 +6,19 @@ - group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) } - if @project && @project.persisted? - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? } -.search.search-form{ class: "#{'has-location-badge' if label.present?}" } +.search.search-form = form_tag search_path, method: :get, class: 'form-inline' do |f| .search-input-container - - if label.present? - .location-badge= label .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: _('Search'), + = search_field_tag 'search', nil, placeholder: _('Search or jump to…'), class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { issues_path: issues_dashboard_path, mr_path: merge_requests_dashboard_path }, - aria: { label: _('Search') } + aria: { label: _('Search or jump to…') } %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } } .dropdown-menu.dropdown-select = dropdown_content do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 2c262a2b7dd..34f47806205 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -4,7 +4,7 @@ .context-header = link_to project_path(@project), title: @project.name do .avatar-container.s40.project-avatar - = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile') + = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40) .sidebar-context-title = @project.name %ul.sidebar-top-level-items diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index e7044f722c5..6f08a294c5d 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -31,17 +31,37 @@ %hr = link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted' - - if show_user_status_field? - %hr - .row - .col-lg-4.profile-settings-sidebar - %h4.prepend-top-0= s_("User|Current Status") - %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface. The message can contain emoji codes, too.") - .col-lg-8 - .row - = f.fields_for :status, @user.status do |status_form| - = status_form.text_field :emoji - = status_form.text_field :message, maxlength: 100 + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0= s_("User|Current status") + %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") + .col-lg-8 + = f.fields_for :status, @user.status do |status_form| + - emoji_button = button_tag type: :button, + class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn has-tooltip', + title: s_("Profiles|Add status emoji") do + - if @user.status + = emoji_icon @user.status.emoji + %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) } + = sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral') + = sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive') + = sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive') + - reset_message_button = button_tag type: :button, + id: 'js-clear-user-status-button', + class: 'clear-user-status btn has-tooltip', + title: s_("Profiles|Clear status") do + = sprite_icon("close") + + = status_form.hidden_field :emoji, id: 'js-status-emoji-field' + = status_form.text_field :message, + id: 'js-status-message-field', + class: 'form-control input-lg', + label: s_("Profiles|Your status"), + prepend: emoji_button, + append: reset_message_button, + placeholder: s_("Profiles|What's your status?") + %hr .row .col-lg-4.profile-settings-sidebar diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 74ab8cf8250..fbe88ec9618 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,7 +3,7 @@ .project-home-panel.text-center{ class: ("empty-project" if empty_repo) } .limit-container-width{ class: container_class } .avatar-container.s70.project-avatar - = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile') + = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile', width: 70, height: 70) %h1.project-title.qa-project-name = @project.name %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 3da6db08580..70e1c557547 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -18,10 +18,14 @@ - if bitbucket_import_enabled? %div = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do - = icon('bitbucket', text: 'Bitbucket') + = icon('bitbucket', text: 'Bitbucket Cloud') - unless bitbucket_import_configured? = render 'bitbucket_import_modal' - + - if bitbucket_server_import_enabled? + %div + = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket" do + = icon('bitbucket-square', text: 'Bitbucket Server') + %div - if gitlab_import_enabled? %div = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index c78baa5dfe4..ad8c7911fad 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -12,7 +12,13 @@ .input-group-prepend.has-tooltip{ title: root_url } .input-group-text = root_url - = f.select :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), {}, { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1} + - namespace_id = namespace_id_from(params) + = f.select(:namespace_id, + namespaces_options(namespace_id || :current_user, + display_path: true, + extra_group: namespace_id), + {}, + { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1}) - else .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 5edab38bd64..a0b0384d78d 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -3,7 +3,8 @@ - page_title @blob.path, @ref -.js-signature-container{ data: { 'signatures-path': namespace_project_signatures_path } } +- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit) +.js-signature-container{ data: { 'signatures-path': signatures_path } } %div{ class: container_class } = render 'projects/last_push' diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index fb1ea471dec..062aa423bde 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -1,5 +1,5 @@ - expanded = Rails.env.test? -%section.qa-deploy-keys-settings.settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded) } .settings-header %h4 Deploy Keys diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml index 578a9e2f74d..8b7535397bc 100644 --- a/app/views/projects/deploy_tokens/_form.html.haml +++ b/app/views/projects/deploy_tokens/_form.html.haml @@ -1,7 +1,7 @@ %p.profile-settings-content = s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.") -= form_for token, url: create_deploy_token_namespace_project_settings_repository_path(project.namespace, project), method: :post do |f| += form_for token, url: create_deploy_token_namespace_project_settings_repository_path(project.namespace, project, anchor: 'js-deploy-tokens'), method: :post do |f| = form_errors(token) .form-group diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 0ff88b82ae6..30544dde451 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -15,6 +15,7 @@ .settings-content .project-edit-errors = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-general-project-settings' } %fieldset .row .form-group.col-md-9 @@ -51,7 +52,7 @@ .form-group - if @project.avatar? .avatar-container.s160.append-bottom-15 - = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160') + = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160) - if @project.avatar_in_git %p.light = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } @@ -75,6 +76,7 @@ Enable or disable certain project features and choose access levels. .settings-content = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } -# haml-lint:disable InlineJavaScript %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project) .js-project-permissions-form @@ -94,6 +96,7 @@ = render_if_exists 'shared/promotions/promote_mr_features' = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } = render 'projects/merge_request_settings', form: f = f.submit 'Save changes', class: "btn btn-save qa-save-merge-request-changes" diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index c2d6c034e35..df2dcf19ed4 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -1,4 +1,5 @@ = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| + %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } .card .card-header %h3.card-title diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 4f1c6c92484..539b184e5c2 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = Rails.env.test? -%section.qa-protected-branches-settings.settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-protected-branches-settings.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded) } .settings-header %h4 Protected Branches diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index 05cee483c0e..81b07af22ad 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -21,4 +21,4 @@ - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning" + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning" diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index 0ae5ca3ff36..f98781b77f4 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -1,4 +1,5 @@ = form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| + %input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' } .card .card-header %h3.card-title diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml index 1702e38df7e..cc6f0309123 100644 --- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml @@ -19,4 +19,4 @@ - if can? current_user, :admin_project, @project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag, { update_section: 'js-protected-tags-settings' }], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 31c2616d283..ab9ba5c7569 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -1,6 +1,6 @@ .row .col-lg-12 - = form_for @project, url: project_settings_ci_cd_path(@project) do |f| + = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f| = form_errors(@project) %fieldset.builds-feature.js-auto-devops-settings .form-group @@ -13,23 +13,15 @@ .card.auto-devops-card .card-body .form-check - = form.radio_button :enabled, 'true', class: 'form-check-input js-toggle-extra-settings' - = form.label :enabled_true, class: 'form-check-label' do - %strong= s_('CICD|Enable Auto DevOps') + = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: @project.auto_devops_enabled? + = form.label :enabled, class: 'form-check-label' do + %strong= s_('CICD|Default to Auto DevOps pipeline') + - if @project.has_auto_devops_implicitly_enabled? + %span.badge.badge-info.js-instance-default-badge= s_('CICD|instance enabled') .form-text.text-muted - = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted } - - .card.auto-devops-card - .card-body - .form-check - = form.radio_button :enabled, '', class: 'form-check-input js-toggle-extra-settings' - = form.label :enabled_, class: 'form-check-label' do - %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" } - .form-text.text-muted - = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted } - - .card.auto-devops-card.js-extra-settings{ class: form.object&.enabled == false ? 'hidden' : nil } - .card-body.bg-light + = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') + = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' + .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' } = form.label :domain do %strong= _('Domain') = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' @@ -46,21 +38,12 @@ .form-check = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' = form.label :deploy_strategy_continuous, class: 'form-check-label' do - %strong= s_('CICD|Continuous deployment to production') + = s_('CICD|Continuous deployment to production') = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank' .form-check = form.radio_button :deploy_strategy, 'manual', class: 'form-check-input' = form.label :deploy_strategy_manual, class: 'form-check-label' do - %strong= s_('CICD|Automatic deployment to staging, manual deployment to production') + = s_('CICD|Automatic deployment to staging, manual deployment to production') = link_to icon('question-circle'), help_page_path('ci/environments.md', anchor: 'manually-deploying-to-environments'), target: '_blank' - .card.auto-devops-card - .card-body - .form-check - = form.radio_button :enabled, 'false', class: 'form-check-input js-toggle-extra-settings', data: { hide_extra_settings: true } - = form.label :enabled_false, class: 'form-check-label' do - %strong= s_('CICD|Disable Auto DevOps') - .form-text.text-muted - = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted } - = f.submit _('Save changes'), class: "btn btn-success prepend-top-15" diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 64751e5616a..434aed2f603 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -1,6 +1,6 @@ .row.prepend-top-default .col-lg-12 - = form_for @project, url: project_settings_ci_cd_path(@project) do |f| + = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f| = form_errors(@project) %fieldset.builds-feature .form-group.append-bottom-default.js-secret-runner-token diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index e011851be78..8a5abb64515 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -9,7 +9,7 @@ = render partial: 'flash_messages', locals: { project: @project } - if @project.repository_exists? && !@project.empty_repo? - - signatures_path = namespace_project_signatures_path(project_id: @project.path, id: @project.default_branch) + - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @project.default_branch) .js-signature-container{ data: { 'signatures-path': signatures_path } } %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index ace8120eeff..9d2aee7a8bd 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -1,7 +1,7 @@ - @no_container = true - breadcrumb_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout -- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.path, project_id: @project.path, id: @ref) +- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit) - page_title @path.presence || _("Files"), @ref = content_for :meta_tags do diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml index 1a353953838..e8681da6528 100644 --- a/app/views/projects/update.js.haml +++ b/app/views/projects/update.js.haml @@ -1,6 +1,7 @@ - if @project.valid? :plain - location.href = "#{edit_project_path(@project)}"; + location.href = "#{edit_project_path(@project, anchor: params[:update_section])}"; + location.reload(); - else :plain $(".project-edit-errors").html("#{escape_javascript(render('errors'))}"); diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index e93925b5ef9..2c3cbd0b986 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -17,13 +17,13 @@ - if can?(current_user, :admin_label, @project) %li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label), dom_id: dom_id(label), type: label.type } } - %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'top' }, aria_label: _('Prioritize label') } + %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Prioritize label') } = sprite_icon('star-o') - %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'top' }, aria_label: _('Deprioritize label') } + %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Deprioritize label') } = sprite_icon('star') - if can?(current_user, :admin_label, label) %li.inline - = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do + = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do = sprite_icon('pencil') %li.inline .dropdown diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 0ae3ab8f090..c5ea15a7f63 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -1,14 +1,17 @@ - subject = local_assigns[:subject] - force_priority = local_assigns.fetch(:force_priority, false) -- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project) -- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project) +- show_label_issues_link = defined?(@project) && show_label_issuables_link?(label, :issues, project: @project) +- show_label_merge_requests_link = defined?(@project) && show_label_issuables_link?(label, :merge_requests, project: @project) .label-name - = link_to_label(label, subject: @project, tooltip: false) + - if defined?(@project) + = link_to_label(label, subject: @project, tooltip: false) + - else + = render_colored_label(label, tooltip: false) .label-description .append-right-default.prepend-left-default - if label.description.present? - .description-text.append-bottom-10 + .description-text = markdown_field(label, :description) %ul.label-links - if show_label_issues_link @@ -19,5 +22,5 @@ %li.label-link-item.inline = link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') } - if force_priority - %li.label-link-item.js-priority-badge.inline.prepend-left-10 + %li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10 .label-badge.label-badge-blue= _('Prioritized label') diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index b35877e5518..e26f5260e5b 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -6,12 +6,13 @@ %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", "aria-hidden": "true" } + = render_if_exists "shared/boards/components/list_milestone" %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" } -# haml-lint:disable AltText %img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } - %span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"", + %span.board-title-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"", ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } } {{ list.title }} diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 607e7f471c9..532045f3697 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -19,6 +19,7 @@ ":value" => "label.id" } .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", + "v-bind:data-selected" => "selectedLabels", data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", @@ -28,7 +29,7 @@ namespace_path: @namespace_path, project_path: @project.try(:path) } } %span.dropdown-toggle-text - = _("Label") + {{ labelDropdownTitle }} = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable = render partial: "shared/issuable/label_page_default" diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 6be1fb485a4..be053d481e4 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -19,7 +19,7 @@ - if project.creator && use_creator_avatar = image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:'' - else - = project_icon(project, alt: '', class: 'avatar project-avatar s40') + = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) .project-details %h3.prepend-top-0.append-bottom-0 = link_to project_path(project), class: 'text-plain' do diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e8b9999f83b..f95df7ecf03 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -77,6 +77,7 @@ - todos_destroyer:todos_destroyer_entity_leave - todos_destroyer:todos_destroyer_project_private - todos_destroyer:todos_destroyer_private_features +- todos_destroyer:todos_destroyer_group_private - default - mailers # ActionMailer::DeliveryJob.queue_name diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb index eaec7d48f35..7d006cc348e 100644 --- a/app/workers/background_migration_worker.rb +++ b/app/workers/background_migration_worker.rb @@ -6,10 +6,22 @@ class BackgroundMigrationWorker # The minimum amount of time between processing two jobs of the same migration # class. # - # This interval is set to 5 minutes so autovacuuming and other maintenance - # related tasks have plenty of time to clean up after a migration has been - # performed. - MIN_INTERVAL = 5.minutes.to_i + # This interval is set to 2 or 5 minutes so autovacuuming and other + # maintenance related tasks have plenty of time to clean up after a migration + # has been performed. + def self.minimum_interval + if enable_health_check? + 2.minutes.to_i + else + 5.minutes.to_i + end + end + + def self.enable_health_check? + Rails.env.development? || + Rails.env.test? || + Feature.enabled?('background_migration_health_check') + end # Performs the background migration. # @@ -27,7 +39,8 @@ class BackgroundMigrationWorker # running a migration of this class or we ran one recently. In this case # we'll reschedule the job in such a way that it is picked up again around # the time the lease expires. - self.class.perform_in(ttl || MIN_INTERVAL, class_name, arguments) + self.class + .perform_in(ttl || self.class.minimum_interval, class_name, arguments) end end @@ -39,17 +52,51 @@ class BackgroundMigrationWorker [true, nil] else lease = lease_for(class_name) + perform = !!lease.try_obtain + + # If we managed to acquire the lease but the DB is not healthy, then we + # want to simply reschedule our job and try again _after_ the lease + # expires. + if perform && !healthy_database? + database_unhealthy_counter.increment - [lease.try_obtain, lease.ttl] + perform = false + end + + [perform, lease.ttl] end end def lease_for(class_name) Gitlab::ExclusiveLease - .new("#{self.class.name}:#{class_name}", timeout: MIN_INTERVAL) + .new(lease_key_for(class_name), timeout: self.class.minimum_interval) + end + + def lease_key_for(class_name) + "#{self.class.name}:#{class_name}" end def always_perform? Rails.env.test? end + + # Returns true if the database is healthy enough to allow the migration to be + # performed. + # + # class_name - The name of the background migration that we might want to + # run. + def healthy_database? + return true unless self.class.enable_health_check? + + return true unless Gitlab::Database.postgresql? + + !Postgresql::ReplicationSlot.lag_too_great? + end + + def database_unhealthy_counter + Gitlab::Metrics.counter( + :background_migration_database_health_reschedules, + 'The number of times a background migration is rescheduled because the database is unhealthy.' + ) + end end diff --git a/app/workers/todos_destroyer/group_private_worker.rb b/app/workers/todos_destroyer/group_private_worker.rb new file mode 100644 index 00000000000..3e47eec7461 --- /dev/null +++ b/app/workers/todos_destroyer/group_private_worker.rb @@ -0,0 +1,10 @@ +module TodosDestroyer + class GroupPrivateWorker + include ApplicationWorker + include TodosDestroyerQueue + + def perform(group_id) + ::Todos::Destroy::GroupPrivateService.new(group_id).execute + end + end +end diff --git a/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml b/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml new file mode 100644 index 00000000000..efa13c9ab3c --- /dev/null +++ b/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml @@ -0,0 +1,5 @@ +--- +title: UX improvements to top nav search bar +merge_request: 20537 +author: +type: changed diff --git a/changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml b/changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml new file mode 100644 index 00000000000..de991ef475a --- /dev/null +++ b/changelogs/unreleased/44127-board-label-edit-drop-down-is-showing-incorrect-selected-labels-summary.yml @@ -0,0 +1,5 @@ +--- +title: Board label edit dropdown shows incorrect selected labels summary +merge_request: 20673 +author: +type: fixed diff --git a/changelogs/unreleased/46165-web-ide-branch-picker.yml b/changelogs/unreleased/46165-web-ide-branch-picker.yml new file mode 100644 index 00000000000..ff879cb3d37 --- /dev/null +++ b/changelogs/unreleased/46165-web-ide-branch-picker.yml @@ -0,0 +1,5 @@ +--- +title: Create branch and MR picker for Web IDE +merge_request: 20978 +author: +type: changed diff --git a/changelogs/unreleased/46535-orphaned-uploads.yml b/changelogs/unreleased/46535-orphaned-uploads.yml new file mode 100644 index 00000000000..1cd087a6aad --- /dev/null +++ b/changelogs/unreleased/46535-orphaned-uploads.yml @@ -0,0 +1,5 @@ +--- +title: Clean orphaned files in object storage +merge_request: 20918 +author: +type: added diff --git a/changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml b/changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml new file mode 100644 index 00000000000..5b91c6d5a9f --- /dev/null +++ b/changelogs/unreleased/46703-group-dashboard-line-height-is-too-tall-for-group-names.yml @@ -0,0 +1,5 @@ +--- +title: Solves group dashboard line height is too tall for group names. +merge_request: 21033 +author: +type: fixed diff --git a/changelogs/unreleased/47156-improve-auto-devops-settings.yml b/changelogs/unreleased/47156-improve-auto-devops-settings.yml new file mode 100644 index 00000000000..d8993565047 --- /dev/null +++ b/changelogs/unreleased/47156-improve-auto-devops-settings.yml @@ -0,0 +1,5 @@ +--- +title: Improve and simplify Auto DevOps settings flow +merge_request: 20946 +author: +type: other diff --git a/changelogs/unreleased/47768-web-ide-redesign-header.yml b/changelogs/unreleased/47768-web-ide-redesign-header.yml new file mode 100644 index 00000000000..49133158164 --- /dev/null +++ b/changelogs/unreleased/47768-web-ide-redesign-header.yml @@ -0,0 +1,5 @@ +--- +title: Redesign Web IDE back button and context header +merge_request: 20850 +author: +type: changed diff --git a/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml b/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml new file mode 100644 index 00000000000..43125ef25c4 --- /dev/null +++ b/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml @@ -0,0 +1,6 @@ +--- +title: Ensure installed Helm Tiller For GitLab Managed Apps Is protected by mutual + auth +merge_request: 20928 +author: +type: changed diff --git a/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml b/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml new file mode 100644 index 00000000000..b3ccbb121f0 --- /dev/null +++ b/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml @@ -0,0 +1,5 @@ +--- +title: fix height of full-width Metrics charts on large screens +merge_request: 20866 +author: +type: fixed diff --git a/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml b/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml new file mode 100644 index 00000000000..c34750a3b88 --- /dev/null +++ b/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml @@ -0,0 +1,5 @@ +--- +title: Fix the UI for listing system-level labels +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/48657-persist-auto-devops-banner-dismissal-per-user-cookie.yml b/changelogs/unreleased/48657-persist-auto-devops-banner-dismissal-per-user-cookie.yml new file mode 100644 index 00000000000..7ee018ef679 --- /dev/null +++ b/changelogs/unreleased/48657-persist-auto-devops-banner-dismissal-per-user-cookie.yml @@ -0,0 +1,5 @@ +--- +title: Persist 'Auto DevOps' banner dismissal globally +merge_request: 20540 +author: +type: other diff --git a/changelogs/unreleased/49375-move-help-popover.yml b/changelogs/unreleased/49375-move-help-popover.yml new file mode 100644 index 00000000000..2547d5768bf --- /dev/null +++ b/changelogs/unreleased/49375-move-help-popover.yml @@ -0,0 +1,5 @@ +--- +title: Moves help_popover component to a common location +merge_request: +author: +type: other diff --git a/changelogs/unreleased/49835-increase-width.yml b/changelogs/unreleased/49835-increase-width.yml new file mode 100644 index 00000000000..f963c0c5e47 --- /dev/null +++ b/changelogs/unreleased/49835-increase-width.yml @@ -0,0 +1,5 @@ +--- +title: Increases title column on modal for reports +merge_request: +author: +type: other diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml new file mode 100644 index 00000000000..ffa4a3bc710 --- /dev/null +++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml @@ -0,0 +1,5 @@ +--- +title: Fix rendering of the context lines in MR diffs page. +merge_request: 20968 +author: +type: fixed diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml new file mode 100644 index 00000000000..42b0e4194f1 --- /dev/null +++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml @@ -0,0 +1,5 @@ +--- +title: Fix autosave and ESC confirmation issues for MR discussions. +merge_request: 20968 +author: +type: fixed diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml new file mode 100644 index 00000000000..29419091d02 --- /dev/null +++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml @@ -0,0 +1,5 @@ +--- +title: Fix navigation to First and Next discussion on MR Changes tab. +merge_request: 20968 +author: +type: fixed diff --git a/changelogs/unreleased/49966-improve-junit-fe.yml b/changelogs/unreleased/49966-improve-junit-fe.yml new file mode 100644 index 00000000000..48971d3bfd6 --- /dev/null +++ b/changelogs/unreleased/49966-improve-junit-fe.yml @@ -0,0 +1,5 @@ +--- +title: Renders test reports for resolved failures and resets error state +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/add-homepage-link-to-status-pages.yml b/changelogs/unreleased/add-homepage-link-to-status-pages.yml new file mode 100644 index 00000000000..0e7375f2061 --- /dev/null +++ b/changelogs/unreleased/add-homepage-link-to-status-pages.yml @@ -0,0 +1,5 @@ +--- +title: Add link to homepage on static http status pages (404, 500, etc) +merge_request: 20898 +author: Jason Funk +type: added diff --git a/changelogs/unreleased/ce-5666-backport.yml b/changelogs/unreleased/ce-5666-backport.yml new file mode 100644 index 00000000000..344f1a1983f --- /dev/null +++ b/changelogs/unreleased/ce-5666-backport.yml @@ -0,0 +1,5 @@ +--- +title: CE port of "List groups with developer maintainer access on project creation" +merge_request: 21051 +author: +type: other diff --git a/changelogs/unreleased/fix-prometheus-updated-status.yml b/changelogs/unreleased/fix-prometheus-updated-status.yml new file mode 100644 index 00000000000..7261c3429c8 --- /dev/null +++ b/changelogs/unreleased/fix-prometheus-updated-status.yml @@ -0,0 +1,5 @@ +--- +title: Fix UI error whereby prometheus application status is updated +merge_request: 21029 +author: +type: fixed diff --git a/changelogs/unreleased/git-rerere-link-doc-update.yml b/changelogs/unreleased/git-rerere-link-doc-update.yml new file mode 100644 index 00000000000..06093e8ec13 --- /dev/null +++ b/changelogs/unreleased/git-rerere-link-doc-update.yml @@ -0,0 +1,5 @@ +--- +title: Update git rerere link in docs +merge_request: 21060 +author: gfyoung +type: other diff --git a/changelogs/unreleased/ide-codesandbox-poc.yml b/changelogs/unreleased/ide-codesandbox-poc.yml new file mode 100644 index 00000000000..7da1f4e6472 --- /dev/null +++ b/changelogs/unreleased/ide-codesandbox-poc.yml @@ -0,0 +1,5 @@ +--- +title: Added live preview for JavaScript projects in the Web IDE +merge_request: 19764 +author: +type: added diff --git a/changelogs/unreleased/improve-junit-support-be.yml b/changelogs/unreleased/improve-junit-support-be.yml new file mode 100644 index 00000000000..db4d47caa7c --- /dev/null +++ b/changelogs/unreleased/improve-junit-support-be.yml @@ -0,0 +1,5 @@ +--- +title: Improve JUnit test reports in merge request widgets +merge_request: 49966 +author: +type: fixed diff --git a/changelogs/unreleased/issue_43602.yml b/changelogs/unreleased/issue_43602.yml new file mode 100644 index 00000000000..0482606db0a --- /dev/null +++ b/changelogs/unreleased/issue_43602.yml @@ -0,0 +1,5 @@ +--- +title: Allow multiple JIRA transition ids +merge_request: 20939 +author: +type: changed diff --git a/changelogs/unreleased/issue_44821.yml b/changelogs/unreleased/issue_44821.yml new file mode 100644 index 00000000000..b1807e069af --- /dev/null +++ b/changelogs/unreleased/issue_44821.yml @@ -0,0 +1,5 @@ +--- +title: Retrieve merge request closing issues from database cache +merge_request: 20911 +author: +type: fixed diff --git a/changelogs/unreleased/jr-archive-hook.yml b/changelogs/unreleased/jr-archive-hook.yml new file mode 100644 index 00000000000..56c13f1370e --- /dev/null +++ b/changelogs/unreleased/jr-archive-hook.yml @@ -0,0 +1,5 @@ +--- +title: Trigger system hooks when project is archived/unarchived +merge_request: 20995 +author: +type: added diff --git a/changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml b/changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml new file mode 100644 index 00000000000..c15d73a0c12 --- /dev/null +++ b/changelogs/unreleased/kp-6927-epic-dates-from-milestone.yml @@ -0,0 +1,5 @@ +--- +title: Add 'tabindex' attribute support on Icon component to show BS4 popover on trigger type 'focus' +merge_request: 21066 +author: +type: other diff --git a/changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml b/changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml new file mode 100644 index 00000000000..62416b7f87e --- /dev/null +++ b/changelogs/unreleased/osw-fix-missing-and-duplicated-milestones-on-list.yml @@ -0,0 +1,5 @@ +--- +title: Fix missing and duplicates on project milestone listing page +merge_request: 21058 +author: +type: fixed diff --git a/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml b/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml new file mode 100644 index 00000000000..dc8148fa1a5 --- /dev/null +++ b/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml @@ -0,0 +1,5 @@ +--- +title: Avoid N+1 on MRs page when metrics merging date cannot be found +merge_request: 21053 +author: +type: performance diff --git a/changelogs/unreleased/pl-json-gon.yml b/changelogs/unreleased/pl-json-gon.yml new file mode 100644 index 00000000000..c0f93006c07 --- /dev/null +++ b/changelogs/unreleased/pl-json-gon.yml @@ -0,0 +1,5 @@ +--- +title: Don't set gon variables in JSON requests +merge_request: 21016 +author: Peter Leitzen +type: performance diff --git a/changelogs/unreleased/sh-bump-gitaly-0-117.yml b/changelogs/unreleased/sh-bump-gitaly-0-117.yml new file mode 100644 index 00000000000..90ca86d076b --- /dev/null +++ b/changelogs/unreleased/sh-bump-gitaly-0-117.yml @@ -0,0 +1,5 @@ +--- +title: Bump Gitaly to 0.117.0 +merge_request: 21055 +author: +type: performance diff --git a/changelogs/unreleased/todos-visibility-migration.yml b/changelogs/unreleased/todos-visibility-migration.yml new file mode 100644 index 00000000000..651facc4ec8 --- /dev/null +++ b/changelogs/unreleased/todos-visibility-migration.yml @@ -0,0 +1,5 @@ +--- +title: Remove todos of users without access to targets migration +merge_request: 20927 +author: +type: other diff --git a/changelogs/unreleased/tz-mr-port-memory-fixes.yml b/changelogs/unreleased/tz-mr-port-memory-fixes.yml new file mode 100644 index 00000000000..61d3c9abf71 --- /dev/null +++ b/changelogs/unreleased/tz-mr-port-memory-fixes.yml @@ -0,0 +1,5 @@ +--- +title: Improve performance and memory footprint of Changes tab of Merge Requests +merge_request: 21028 +author: +type: performance diff --git a/changelogs/unreleased/winh-fix-gpg-regressions.yml b/changelogs/unreleased/winh-fix-gpg-regressions.yml new file mode 100644 index 00000000000..75d28321259 --- /dev/null +++ b/changelogs/unreleased/winh-fix-gpg-regressions.yml @@ -0,0 +1,5 @@ +--- +title: Fix GPG status badge loading regressions +merge_request: 20987 +author: +type: fixed diff --git a/changelogs/unreleased/winh-restyle-user-status.yml b/changelogs/unreleased/winh-restyle-user-status.yml new file mode 100644 index 00000000000..90370e87825 --- /dev/null +++ b/changelogs/unreleased/winh-restyle-user-status.yml @@ -0,0 +1,5 @@ +--- +title: Restyle status message input on profile settings +merge_request: 20903 +author: +type: changed diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 73115449871..dce1fc1bc45 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -546,3 +546,27 @@ :why: Our own library :versions: [] :when: 2018-07-17 21:02:54.529227000 Z +- - :approve + - lz-string + - :who: Phil Hughes + :why: https://github.com/pieroxy/lz-string/blob/master/LICENSE.txt + :versions: [] + :when: 2018-08-03 08:22:44.973457000 Z +- - :approve + - smooshpack + - :who: Phil Hughes + :why: https://github.com/CompuIves/codesandbox-client/blob/master/packages/sandpack/LICENSE.md + :versions: [] + :when: 2018-08-03 08:24:29.578991000 Z +- - :approve + - codesandbox-import-util-types + - :who: Phil Hughes + :why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/types/LICENSE + :versions: [] + :when: 2018-08-03 12:22:47.574421000 Z +- - :approve + - codesandbox-import-utils + - :who: Phil Hughes + :why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/import-utils/LICENSE + :versions: [] + :when: 2018-08-03 12:23:24.083046000 Z diff --git a/config/initializers/active_record_verbose_query_logs.rb b/config/initializers/active_record_verbose_query_logs.rb new file mode 100644 index 00000000000..44f86fec7e0 --- /dev/null +++ b/config/initializers/active_record_verbose_query_logs.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# This is backport of https://github.com/rails/rails/pull/26815/files +# Enabled by default for every non-production environment + +module ActiveRecord + class LogSubscriber + module VerboseQueryLogs + def debug(progname = nil, &block) + return unless super + + log_query_source + end + + def log_query_source + source_line, line_number = extract_callstack(caller_locations) + + if source_line + if defined?(::Rails.root) + app_root = "#{::Rails.root}/".freeze + source_line = source_line.sub(app_root, "") + end + + logger.debug(" ↳ #{source_line}:#{line_number}") + end + end + + def extract_callstack(callstack) + line = callstack.find do |frame| + frame.absolute_path && !ignored_callstack(frame.absolute_path) + end + + offending_line = line || callstack.first + [ + offending_line.path, + offending_line.lineno, + offending_line.label + ] + end + + LOG_SUBSCRIBER_FILE = ActiveRecord::LogSubscriber.method(:logger).source_location.first + RAILS_GEM_ROOT = File.expand_path("../../../..", LOG_SUBSCRIBER_FILE) + "/" + APP_CONFIG_ROOT = File.expand_path("..", __dir__) + "/" + + def ignored_callstack(path) + path.start_with?(APP_CONFIG_ROOT, RAILS_GEM_ROOT, RbConfig::CONFIG["rubylibdir"]) + end + end + + unless Gitlab.rails5? + prepend(VerboseQueryLogs) unless Rails.env.production? + end + end +end diff --git a/config/routes/import.rb b/config/routes/import.rb index efd0260ff60..3998d977c81 100644 --- a/config/routes/import.rb +++ b/config/routes/import.rb @@ -24,6 +24,13 @@ namespace :import do get :jobs end + resource :bitbucket_server, only: [:create, :new], controller: :bitbucket_server do + post :configure + get :status + get :callback + get :jobs + end + resource :google_code, only: [:create, :new], controller: :google_code do get :status post :callback diff --git a/db/migrate/20180608091413_add_group_to_todos.rb b/db/migrate/20180608091413_add_group_to_todos.rb new file mode 100644 index 00000000000..20ba4849057 --- /dev/null +++ b/db/migrate/20180608091413_add_group_to_todos.rb @@ -0,0 +1,36 @@ +class AddGroupToTodos < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + class Todo < ActiveRecord::Base + self.table_name = 'todos' + + include ::EachBatch + end + + def up + add_column(:todos, :group_id, :integer) unless group_id_exists? + add_concurrent_foreign_key :todos, :namespaces, column: :group_id, on_delete: :cascade + add_concurrent_index :todos, :group_id + + change_column_null :todos, :project_id, true + end + + def down + remove_foreign_key_without_error(:todos, column: :group_id) + remove_concurrent_index(:todos, :group_id) + remove_column(:todos, :group_id) if group_id_exists? + + Todo.where(project_id: nil).each_batch { |batch| batch.delete_all } + change_column_null :todos, :project_id, false + end + + private + + def group_id_exists? + column_exists?(:todos, :group_id) + end +end diff --git a/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb b/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb new file mode 100644 index 00000000000..57cea18abcd --- /dev/null +++ b/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class AddColumnsForHelmTillerCertificates < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :clusters_applications_helm, :encrypted_ca_key, :text + add_column :clusters_applications_helm, :encrypted_ca_key_iv, :text + add_column :clusters_applications_helm, :ca_cert, :text + end +end diff --git a/db/migrate/20180717125853_remove_restricted_todos.rb b/db/migrate/20180717125853_remove_restricted_todos.rb new file mode 100644 index 00000000000..fdf43921a73 --- /dev/null +++ b/db/migrate/20180717125853_remove_restricted_todos.rb @@ -0,0 +1,31 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. +# frozen_string_literal: true + +class RemoveRestrictedTodos < ActiveRecord::Migration + DOWNTIME = false + disable_ddl_transaction! + + MIGRATION = 'RemoveRestrictedTodos'.freeze + BATCH_SIZE = 1000 + DELAY_INTERVAL = 5.minutes.to_i + + class Project < ActiveRecord::Base + include EachBatch + + self.table_name = 'projects' + end + + def up + Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)') + .each_batch(of: BATCH_SIZE) do |batch, index| + range = batch.pluck('MIN(id)', 'MAX(id)').first + + BackgroundMigrationWorker.perform_in(index * DELAY_INTERVAL, MIGRATION, range) + end + end + + def down + # nothing to do + end +end diff --git a/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb new file mode 100644 index 00000000000..1ebb91da00c --- /dev/null +++ b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddWebIdeClientSidePreviewEnabledToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:application_settings, :web_ide_clientside_preview_enabled, + :boolean, + default: false, + allow_null: false) + end + + def down + remove_column(:application_settings, :web_ide_clientside_preview_enabled) + end +end diff --git a/db/schema.rb b/db/schema.rb index 7768925a2df..f1d8f4df3b7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180807153545) do t.boolean "mirror_available", default: true, null: false t.boolean "hide_third_party_offers", default: false, null: false t.boolean "instance_statistics_visibility_private", default: false, null: false + t.boolean "web_ide_clientside_preview_enabled", default: false, null: false end create_table "audit_events", force: :cascade do |t| @@ -636,6 +637,9 @@ ActiveRecord::Schema.define(version: 20180807153545) do t.integer "status", null: false t.string "version", null: false t.text "status_reason" + t.text "encrypted_ca_key" + t.text "encrypted_ca_key_iv" + t.text "ca_cert" end create_table "clusters_applications_ingress", force: :cascade do |t| @@ -1987,7 +1991,7 @@ ActiveRecord::Schema.define(version: 20180807153545) do create_table "todos", force: :cascade do |t| t.integer "user_id", null: false - t.integer "project_id", null: false + t.integer "project_id" t.integer "target_id" t.string "target_type", null: false t.integer "author_id", null: false @@ -1997,10 +2001,12 @@ ActiveRecord::Schema.define(version: 20180807153545) do t.datetime "updated_at" t.integer "note_id" t.string "commit_id" + t.integer "group_id" end add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree + add_index "todos", ["group_id"], name: "index_todos_on_group_id", using: :btree add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree @@ -2388,6 +2394,7 @@ ActiveRecord::Schema.define(version: 20180807153545) do add_foreign_key "term_agreements", "users", on_delete: :cascade add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade + add_foreign_key "todos", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "todos", "notes", name: "fk_91d1f47b13", on_delete: :cascade add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade add_foreign_key "todos", "users", column: "author_id", name: "fk_ccf0373936", on_delete: :cascade diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index d9a61aea6ef..6d7e408d41b 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -63,7 +63,7 @@ Gitaly network traffic is unencrypted so you should use a firewall to restrict access to your Gitaly server. Below we describe how to configure a Gitaly server at address -`gitaly.internal:9999` with secret token `abc123secret`. We assume +`gitaly.internal:8075` with secret token `abc123secret`. We assume your GitLab installation has two repository storages, `default` and `storage1`. @@ -101,18 +101,42 @@ documentation on configuring Gitaly authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication) . -In most or all cases the storage paths below end in `/repositories`. Check the +> +**NOTE:** In most or all cases the storage paths below end in `/repositories` which is +different than `path` in `git_data_dirs` of Omnibus installations. Check the directory layout on your Gitaly server to be sure. Omnibus installations: ```ruby # /etc/gitlab/gitlab.rb -gitaly['listen_addr'] = '0.0.0.0:9999' + +# Avoid running unnecessary services on the gitaly server +postgresql['enable'] = false +redis['enable'] = false +nginx['enable'] = false +prometheus['enable'] = false +unicorn['enable'] = false +sidekiq['enable'] = false +gitlab_workhorse['enable'] = false + +# Prevent database connections during 'gitlab-ctl reconfigure' +gitlab_rails['rake_cache_clear'] = false +gitlab_rails['auto_migrate'] = false + +# Configure the gitlab-shell API callback URL. Without this, `git push` will +# fail. This can be your 'front door' GitLab URL or an internal load +# balancer. +gitlab_rails['internal_api_url'] = 'https://gitlab.example.com' + +# Make Gitaly accept connections on all network interfaces. You must use +# firewalls to restrict access to this address/port. +gitaly['listen_addr'] = "0.0.0.0:8075" gitaly['auth_token'] = 'abc123secret' + gitaly['storage'] = [ - { 'name' => 'default', 'path' => '/path/to/default/repositories' }, - { 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' }, + { 'name' => 'default', 'path' => '/mnt/gitlab/default/repositories' }, + { 'name' => 'storage1', 'path' => '/mnt/gitlab/storage1/repositories' }, ] ``` @@ -120,18 +144,18 @@ Source installations: ```toml # /home/git/gitaly/config.toml -listen_addr = '0.0.0.0:9999' +listen_addr = '0.0.0.0:8075' [auth] token = 'abc123secret' [[storage] name = 'default' -path = '/path/to/default/repositories' +path = '/mnt/gitlab/default/repositories' [[storage]] name = 'storage1' -path = '/path/to/storage1/repositories' +path = '/mnt/gitlab/storage1/repositories' ``` Again, reconfigure (Omnibus) or restart (source). @@ -146,7 +170,7 @@ server from reaching the Gitaly server then all Gitaly requests will fail. We assume that your Gitaly server can be reached at -`gitaly.internal:9999` from your GitLab server, and that your GitLab +`gitaly.internal:8075` from your GitLab server, and that your GitLab NFS shares are mounted at `/mnt/gitlab/default` and `/mnt/gitlab/storage1` respectively. @@ -155,8 +179,8 @@ Omnibus installations: ```ruby # /etc/gitlab/gitlab.rb git_data_dirs({ - 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitlab.internal:9999' }, - 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitlab.internal:9999' }, + 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitaly.internal:8075' }, + 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitaly.internal:8075' }, }) gitlab_rails['gitaly_token'] = 'abc123secret' @@ -171,10 +195,10 @@ gitlab: storages: default: path: /mnt/gitlab/default/repositories - gitaly_address: tcp://gitlab.internal:9999 + gitaly_address: tcp://gitaly.internal:8075 storage1: path: /mnt/gitlab/storage1/repositories - gitaly_address: tcp://gitlab.internal:9999 + gitaly_address: tcp://gitaly.internal:8075 gitaly: token: 'abc123secret' diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md index 752a2774bd7..eada7b19dcd 100644 --- a/doc/administration/operations/fast_ssh_key_lookup.md +++ b/doc/administration/operations/fast_ssh_key_lookup.md @@ -1,11 +1,9 @@ -# Consider using SSH certificates instead of, or in addition to this +# Fast lookup of authorized SSH keys in the database -This document describes a drop-in replacement for the +NOTE: **Note:** This document describes a drop-in replacement for the `authorized_keys` file for normal (non-deploy key) users. Consider using [ssh certificates](ssh_certificates.md), they are even faster, -but are not is not a drop-in replacement. - -# Fast lookup of authorized SSH keys in the database +but are not a drop-in replacement. > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/1631) in > [GitLab Starter](https://about.gitlab.com/gitlab-ee) 9.3. diff --git a/doc/api/todos.md b/doc/api/todos.md index 27e623007cc..0843e4eedc6 100644 --- a/doc/api/todos.md +++ b/doc/api/todos.md @@ -18,6 +18,7 @@ Parameters: | `action` | string | no | The action to be filtered. Can be `assigned`, `mentioned`, `build_failed`, `marked`, `approval_required`, `unmergeable` or `directly_addressed`. | | `author_id` | integer | no | The ID of an author | | `project_id` | integer | no | The ID of a project | +| `group_id` | integer | no | The ID of a group | | `state` | string | no | The state of the todo. Can be either `pending` or `done` | | `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` | diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md index a85e5b1b1cc..8d41503f874 100644 --- a/doc/development/automatic_ce_ee_merge.md +++ b/doc/development/automatic_ce_ee_merge.md @@ -100,7 +100,7 @@ Notes: number of times you have to resolve conflicts. - Please remember to [always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts). -- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html) +- You can use [`git rerere`](https://git-scm.com/docs/git-rerere) to avoid resolving the same conflicts multiple times. ### Cherry-picking from CE to EE diff --git a/doc/development/background_migrations.md b/doc/development/background_migrations.md index 16195cbbbdf..f0d5af9fcb5 100644 --- a/doc/development/background_migrations.md +++ b/doc/development/background_migrations.md @@ -5,6 +5,9 @@ otherwise take a very long time (hours, days, years, etc) to complete. For example, you can use background migrations to migrate data so that instead of storing data in a single JSON column the data is stored in a separate table. +If the database cluster is considered to be in an unhealthy state, background +migrations automatically reschedule themselves for a later point in time. + ## When To Use Background Migrations >**Note:** diff --git a/doc/raketasks/cleanup.md b/doc/raketasks/cleanup.md index e2eb342361a..e70a009323e 100644 --- a/doc/raketasks/cleanup.md +++ b/doc/raketasks/cleanup.md @@ -52,4 +52,33 @@ D, [2018-07-27T12:08:33.293568 #89817] DEBUG -- : Processing batch of 500 projec I, [2018-07-27T12:08:33.689869 #89817] INFO -- : Did move to lost and found /opt/gitlab/embedded/service/gitlab-rails/public/uploads/test.out -> /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/project-lost-found/test.out I, [2018-07-27T12:08:33.755624 #89817] INFO -- : Did fix /opt/gitlab/embedded/service/gitlab-rails/public/uploads/foo/bar/89a0f7b0b97008a4a18cedccfdcd93fb/foo.txt -> /opt/gitlab/embedded/service/gitlab-rails/public/uploads/qux/foo/bar/89a0f7b0b97008a4a18cedccfdcd93fb/foo.txt I, [2018-07-27T12:08:33.760257 #89817] INFO -- : Did move to lost and found /opt/gitlab/embedded/service/gitlab-rails/public/uploads/foo/bar/1dd6f0f7eefd2acc4c2233f89a0f7b0b/image.png -> /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/project-lost-found/foo/bar/1dd6f0f7eefd2acc4c2233f89a0f7b0b/image.png -```
\ No newline at end of file +``` + +Remove object store upload files if they don't exist in GitLab database. + +``` +# omnibus-gitlab +sudo gitlab-rake gitlab:cleanup:remote_upload_files + +# installation from source +bundle exec rake gitlab:cleanup:remote_upload_files RAILS_ENV=production +``` + +Example output: + +``` +$ sudo gitlab-rake gitlab:cleanup:remote_upload_files + +I, [2018-08-02T10:26:13.995978 #45011] INFO -- : Looking for orphaned remote uploads to remove. Dry run... +I, [2018-08-02T10:26:14.120400 #45011] INFO -- : Can be moved to lost and found: @hashed/6b/DSC_6152.JPG +I, [2018-08-02T10:26:14.120482 #45011] INFO -- : Can be moved to lost and found: @hashed/79/02/7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451/711491b29d3eb08837798c4909e2aa4d/DSC00314.jpg +I, [2018-08-02T10:26:14.120634 #45011] INFO -- : To cleanup these files run this command with DRY_RUN=false +``` + +``` +$ sudo gitlab-rake gitlab:cleanup:remote_upload_files DRY_RUN=false + +I, [2018-08-02T10:26:47.598424 #45087] INFO -- : Looking for orphaned remote uploads to remove... +I, [2018-08-02T10:26:47.753131 #45087] INFO -- : Moved to lost and found: @hashed/6b/DSC_6152.JPG -> lost_and_found/@hashed/6b/DSC_6152.JPG +I, [2018-08-02T10:26:47.764356 #45087] INFO -- : Moved to lost and found: @hashed/79/02/7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451/711491b29d3eb08837798c4909e2aa4d/DSC00314.jpg -> lost_and_found/@hashed/79/02/7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451/711491b29d3eb08837798c4909e2aa4d/DSC00314.jpg +``` diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index 96a08c04905..b1b822f25bd 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -30,6 +30,7 @@ You can edit your account settings by navigating from the up-right corner menu b From there, you can: - Update your personal information +- Set a [custom status](#current-status) for your profile - Manage [2FA](account/two_factor_authentication.md) - Change your username and [delete your account](account/delete_account.md) - Manage applications that can @@ -90,6 +91,27 @@ To enable private profile: NOTE: **Note:** You and GitLab admins can see your the abovementioned information on your profile even if it is private. +## Current status + +> Introduced in GitLab 11.2. + +You can provide a custom status message for your user profile along with an emoji that describes it. +This may be helpful when you are out of office or otherwise not available. +Other users can then take your status into consideration when responding to your issues or assigning work to you. +Please be aware that your status is publicly visible even if your [profile is private](#private-profile). + +To set your current status: + +1. Navigate to your personal [profile settings](#profile-settings). +1. In the text field below `Your status`, enter your status message. +1. Select an emoji from the dropdown if you like. +1. Hit **Update profile settings**. + +Status messages are restricted to 100 characters of plain text. +They may however contain emoji codes such as `I'm on vacation :palm_tree:`. + +You can also set your current status [using the API](../../api/users.md#user-status). + ## Troubleshooting ### Why do I keep getting signed out? diff --git a/doc/user/project/img/labels_project_list_search.png b/doc/user/project/img/labels_project_list_search.png Binary files differnew file mode 100644 index 00000000000..ff9bf92e1c3 --- /dev/null +++ b/doc/user/project/img/labels_project_list_search.png diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index 4d5b2c97291..67c543e00fb 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -113,7 +113,7 @@ in the table below. | `JIRA API URL` | The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., `https://jira-api.example.com`. | | `Username` | The user name created in [configuring JIRA step](#configuring-jira). Using the email address will cause `401 unauthorized`. | | `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). | -| `Transition ID` | This is the ID of a transition that moves issues to the desired state. **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** | +| `Transition ID` | This is the ID of a transition that moves issues to the desired state. It is possible to insert transition ids separated by `,` or `;` which means the issue will be moved to each state after another using the given order. **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** | ### Getting a transition ID diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 6dfdbe6c0d5..49b49271cff 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -119,7 +119,7 @@ Issue Board, that is, create or delete lists and drag issues from one list to an ## Issue Board terminology - **Issue Board** - Each board represents a unique view for your issues. It can have multiple lists with each list consisting of issues represented by cards. -- **List** - A column on the issue board that displays issues matching certain attributes. In addition to the default lists of 'Backlog' and 'Closed' issue, each additional list will show issues matching your chosen label or assignee. +- **List** - A column on the issue board that displays issues matching certain attributes. In addition to the default lists of 'Backlog' and 'Closed' issue, each additional list will show issues matching your chosen label or assignee. On the top of that list you can see the number of issues that belong to it. - **Label list**: a list based on a label. It shows all opened issues with that label. - **Assignee list**: a list which includes all issues assigned to a user. - **Backlog** (default): shows all open issues that do not belong to one of the other lists. Always appears as the leftmost list. diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index 914898ea2ea..3ae6dbe585e 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -69,6 +69,16 @@ Every issue and merge request can be assigned any number of labels. The labels a |:---:|:---:| |  |  | +## Searching for project labels + +You can search for project labels by navigating from the left sidebar to your +project's **Issues > Labels** and entering your query to the search bar on the +top-right: + + + +GitLab will consider the label title and description for the search. + ## Filtering issues and merge requests by label ### Filtering in list pages diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index b0143e45ab6..511ac2d7e79 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -59,9 +59,18 @@ left. > [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0. Switching between your authored and assigned merge requests can be done without -leaving the Web IDE. Click the project name in the top left to open a list of -merge requests. You will need to commit or discard all your changes before +leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list +of merge requests. You will need to commit or discard all your changes before switching to a different merge request. +## Switching branches + +> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) [GitLab Core][ce] 11.2. + +Switching between branches of the current project repository can be done without +leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list +of branches. You will need to commit or discard all your changes before +switching to a different branch. + [ce]: https://about.gitlab.com/pricing/ [ee]: https://about.gitlab.com/pricing/ diff --git a/doc/user/search/img/issues_mrs_shortcut.png b/doc/user/search/img/issues_mrs_shortcut.png Binary files differindex 6380b337b54..cf43df98aa0 100644 --- a/doc/user/search/img/issues_mrs_shortcut.png +++ b/doc/user/search/img/issues_mrs_shortcut.png diff --git a/doc/user/search/img/project_search.png b/doc/user/search/img/project_search.png Binary files differindex 3150b40de29..0b76d7d6038 100644 --- a/doc/user/search/img/project_search.png +++ b/doc/user/search/img/project_search.png diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md index 760cd87d4cc..dda82352c67 100644 --- a/doc/workflow/todos.md +++ b/doc/workflow/todos.md @@ -109,6 +109,7 @@ There are four kinds of filters you can use on your Todos dashboard. | Filter | Description | | ------- | ----------- | | Project | Filter by project | +| Group | Filter by group | | Author | Filter by the author that triggered the Todo | | Type | Filter by issue or merge request | | Action | Filter by the action that triggered the Todo | diff --git a/lib/api/boards.rb b/lib/api/boards.rb index 086d39d5070..0f89414148b 100644 --- a/lib/api/boards.rb +++ b/lib/api/boards.rb @@ -71,12 +71,10 @@ module API success Entities::List end params do - requires :label_id, type: Integer, desc: 'The ID of an existing label' + use :list_creation_params end post '/lists' do - unless available_labels_for(user_project).exists?(params[:label_id]) - render_api_error!({ error: 'Label not found!' }, 400) - end + authorize_list_type_resource! authorize!(:admin_list, user_project) diff --git a/lib/api/boards_responses.rb b/lib/api/boards_responses.rb index ead0943a74d..7e873012efe 100644 --- a/lib/api/boards_responses.rb +++ b/lib/api/boards_responses.rb @@ -14,7 +14,7 @@ module API def create_list create_list_service = - ::Boards::Lists::CreateService.new(board_parent, current_user, { label_id: params[:label_id] }) + ::Boards::Lists::CreateService.new(board_parent, current_user, create_list_params) list = create_list_service.execute(board) @@ -25,6 +25,10 @@ module API end end + def create_list_params + params.slice(:label_id) + end + def move_list(list) move_list_service = ::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i }) @@ -44,6 +48,16 @@ module API end end end + + def authorize_list_type_resource! + unless available_labels_for(board_parent).exists?(params[:label_id]) + render_api_error!({ error: 'Label not found!' }, 400) + end + end + + params :list_creation_params do + requires :label_id, type: Integer, desc: 'The ID of an existing label' + end end end end diff --git a/lib/api/branches.rb b/lib/api/branches.rb index 4b223a391ae..3e445e6b1fa 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -19,6 +19,7 @@ module API params :filter_params do optional :search, type: String, desc: 'Return list of branches matching the search criteria' + optional :sort, type: String, desc: 'Return list of branches sorted by the given field' end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index f858d9fa23d..27f28e1df93 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -795,28 +795,33 @@ module API class Todo < Grape::Entity expose :id - expose :project, using: Entities::BasicProjectDetails + expose :project, using: Entities::ProjectIdentity, if: -> (todo, _) { todo.project_id } + expose :group, using: 'API::Entities::NamespaceBasic', if: -> (todo, _) { todo.group_id } expose :author, using: Entities::UserBasic expose :action_name expose :target_type expose :target do |todo, options| - Entities.const_get(todo.target_type).represent(todo.target, options) + todo_target_class(todo.target_type).represent(todo.target, options) end expose :target_url do |todo, options| target_type = todo.target_type.underscore - target_url = "namespace_project_#{target_type}_url" + target_url = "#{todo.parent.class.to_s.underscore}_#{target_type}_url" target_anchor = "note_#{todo.note_id}" if todo.note_id? Gitlab::Routing .url_helpers - .public_send(target_url, todo.project.namespace, todo.project, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend + .public_send(target_url, todo.parent, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend end expose :body expose :state expose :created_at + + def todo_target_class(target_type) + ::API::Entities.const_get(target_type) + end end class NamespaceBasic < Grape::Entity diff --git a/lib/api/group_boards.rb b/lib/api/group_boards.rb index aa9fff25fc8..3832cdc10a8 100644 --- a/lib/api/group_boards.rb +++ b/lib/api/group_boards.rb @@ -70,12 +70,10 @@ module API success Entities::List end params do - requires :label_id, type: Integer, desc: 'The ID of an existing label' + use :list_creation_params end post '/lists' do - unless available_labels_for(board_parent).exists?(params[:label_id]) - render_api_error!({ error: 'Label not found!' }, 400) - end + authorize_list_type_resource! authorize!(:admin_list, user_group) diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 2621c9f8fc2..abad418771c 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -380,7 +380,7 @@ module API end get ':id/merge_requests/:merge_request_iid/closes_issues' do merge_request = find_merge_request_with_access(params[:merge_request_iid]) - issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user)) + issues = ::Kaminari.paginate_array(merge_request.visible_closing_issues_for(current_user)) issues = paginate(issues) external_issues, internal_issues = issues.partition { |issue| issue.is_a?(ExternalIssue) } diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 7adde79d6c3..5738bf220c6 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -321,7 +321,7 @@ module API post ':id/archive' do authorize!(:archive_project, user_project) - user_project.archive! + ::Projects::UpdateService.new(user_project, current_user, archived: true).execute present user_project, with: Entities::Project end @@ -332,7 +332,7 @@ module API post ':id/unarchive' do authorize!(:archive_project, user_project) - user_project.unarchive! + ::Projects::UpdateService.new(@project, current_user, archived: false).execute present user_project, with: Entities::Project end diff --git a/lib/bitbucket_server/client.rb b/lib/bitbucket_server/client.rb new file mode 100644 index 00000000000..15e59f93141 --- /dev/null +++ b/lib/bitbucket_server/client.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module BitbucketServer + class Client + attr_reader :connection + + ServerError = Class.new(StandardError) + + SERVER_ERRORS = [SocketError, + OpenSSL::SSL::SSLError, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Net::OpenTimeout, + Net::ReadTimeout, + Gitlab::HTTP::BlockedUrlError, + BitbucketServer::Connection::ConnectionError].freeze + + def initialize(options = {}) + @connection = Connection.new(options) + end + + def pull_requests(project_key, repo) + path = "/projects/#{project_key}/repos/#{repo}/pull-requests?state=ALL" + get_collection(path, :pull_request) + end + + def activities(project_key, repo, pull_request_id) + path = "/projects/#{project_key}/repos/#{repo}/pull-requests/#{pull_request_id}/activities" + get_collection(path, :activity) + end + + def repo(project, repo_name) + parsed_response = connection.get("/projects/#{project}/repos/#{repo_name}") + BitbucketServer::Representation::Repo.new(parsed_response) + end + + def repos + path = "/repos" + get_collection(path, :repo) + end + + def create_branch(project_key, repo, branch_name, sha) + payload = { + name: branch_name, + startPoint: sha, + message: 'GitLab temporary branch for import' + } + + connection.post("/projects/#{project_key}/repos/#{repo}/branches", payload.to_json) + end + + def delete_branch(project_key, repo, branch_name, sha) + payload = { + name: Gitlab::Git::BRANCH_REF_PREFIX + branch_name, + dryRun: false + } + + connection.delete(:branches, "/projects/#{project_key}/repos/#{repo}/branches", payload.to_json) + end + + private + + def get_collection(path, type) + paginator = BitbucketServer::Paginator.new(connection, Addressable::URI.escape(path), type) + BitbucketServer::Collection.new(paginator) + rescue *SERVER_ERRORS => e + raise ServerError, e + end + end +end diff --git a/lib/bitbucket_server/collection.rb b/lib/bitbucket_server/collection.rb new file mode 100644 index 00000000000..b50c5dde352 --- /dev/null +++ b/lib/bitbucket_server/collection.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module BitbucketServer + class Collection < Enumerator + def initialize(paginator) + super() do |yielder| + loop do + paginator.items.each { |item| yielder << item } + end + end + + lazy + end + + def method_missing(method, *args) + return super unless self.respond_to?(method) + + self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend + block_given? ? yield(item) : item + end + end + end +end diff --git a/lib/bitbucket_server/connection.rb b/lib/bitbucket_server/connection.rb new file mode 100644 index 00000000000..45a437844bd --- /dev/null +++ b/lib/bitbucket_server/connection.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module BitbucketServer + class Connection + include ActionView::Helpers::SanitizeHelper + + DEFAULT_API_VERSION = '1.0' + SEPARATOR = '/' + + attr_reader :api_version, :base_uri, :username, :token + + ConnectionError = Class.new(StandardError) + + def initialize(options = {}) + @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) + @base_uri = options[:base_uri] + @username = options[:user] + @token = options[:password] + end + + def get(path, extra_query = {}) + response = Gitlab::HTTP.get(build_url(path), + basic_auth: auth, + headers: accept_headers, + query: extra_query) + + check_errors!(response) + + response.parsed_response + end + + def post(path, body) + response = Gitlab::HTTP.post(build_url(path), + basic_auth: auth, + headers: post_headers, + body: body) + + check_errors!(response) + + response.parsed_response + end + + # We need to support two different APIs for deletion: + # + # /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/branches/default + # /rest/branch-utils/1.0/projects/{projectKey}/repos/{repositorySlug}/branches + def delete(resource, path, body) + url = delete_url(resource, path) + + response = Gitlab::HTTP.delete(url, + basic_auth: auth, + headers: post_headers, + body: body) + + check_errors!(response) + + response.parsed_response + end + + private + + def check_errors!(response) + raise ConnectionError, "Response is not valid JSON" unless response.parsed_response.is_a?(Hash) + + return if response.code >= 200 && response.code < 300 + + details = sanitize(response.parsed_response.dig('errors', 0, 'message')) + message = "Error #{response.code}" + message += ": #{details}" if details + + raise ConnectionError, message + rescue JSON::ParserError + raise ConnectionError, "Unable to parse the server response as JSON" + end + + def auth + @auth ||= { username: username, password: token } + end + + def accept_headers + @accept_headers ||= { 'Accept' => 'application/json' } + end + + def post_headers + @post_headers ||= accept_headers.merge({ 'Content-Type' => 'application/json' }) + end + + def build_url(path) + return path if path.starts_with?(root_url) + + url_join_paths(root_url, path) + end + + def root_url + url_join_paths(base_uri, "/rest/api/#{api_version}") + end + + def delete_url(resource, path) + if resource == :branches + url_join_paths(base_uri, "/rest/branch-utils/#{api_version}#{path}") + else + build_url(path) + end + end + + # URI.join is stupid in that slashes are important: + # + # # URI.join('http://example.com/subpath', 'hello') + # => http://example.com/hello + # + # We really want http://example.com/subpath/hello + # + def url_join_paths(*paths) + paths.map { |path| strip_slashes(path) }.join(SEPARATOR) + end + + def strip_slashes(path) + path = path[1..-1] if path.starts_with?(SEPARATOR) + path.chomp(SEPARATOR) + end + end +end diff --git a/lib/bitbucket_server/page.rb b/lib/bitbucket_server/page.rb new file mode 100644 index 00000000000..5d9a3168876 --- /dev/null +++ b/lib/bitbucket_server/page.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module BitbucketServer + class Page + attr_reader :attrs, :items + + def initialize(raw, type) + @attrs = parse_attrs(raw) + @items = parse_values(raw, representation_class(type)) + end + + def next? + !attrs.fetch(:isLastPage, true) + end + + def next + attrs.fetch(:nextPageStart) + end + + private + + def parse_attrs(raw) + raw.slice('size', 'nextPageStart', 'isLastPage').symbolize_keys + end + + def parse_values(raw, bitbucket_rep_class) + return [] unless raw['values'] && raw['values'].is_a?(Array) + + bitbucket_rep_class.decorate(raw['values']) + end + + def representation_class(type) + BitbucketServer::Representation.const_get(type.to_s.camelize) + end + end +end diff --git a/lib/bitbucket_server/paginator.rb b/lib/bitbucket_server/paginator.rb new file mode 100644 index 00000000000..c351fb2f11f --- /dev/null +++ b/lib/bitbucket_server/paginator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module BitbucketServer + class Paginator + PAGE_LENGTH = 25 + + def initialize(connection, url, type) + @connection = connection + @type = type + @url = url + @page = nil + end + + def items + raise StopIteration unless has_next_page? + + @page = fetch_next_page + @page.items + end + + private + + attr_reader :connection, :page, :url, :type + + def has_next_page? + page.nil? || page.next? + end + + def next_offset + page.nil? ? 0 : page.next + end + + def fetch_next_page + parsed_response = connection.get(@url, start: next_offset, limit: PAGE_LENGTH) + Page.new(parsed_response, type) + end + end +end diff --git a/lib/bitbucket_server/representation/activity.rb b/lib/bitbucket_server/representation/activity.rb new file mode 100644 index 00000000000..08bf30a5d1e --- /dev/null +++ b/lib/bitbucket_server/representation/activity.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class Activity < Representation::Base + def comment? + action == 'COMMENTED' + end + + def inline_comment? + !!(comment? && comment_anchor) + end + + def comment + return unless comment? + + @comment ||= + if inline_comment? + PullRequestComment.new(raw) + else + Comment.new(raw) + end + end + + # TODO Move this into MergeEvent + def merge_event? + action == 'MERGED' + end + + def committer_user + commit.dig('committer', 'displayName') + end + + def committer_email + commit.dig('committer', 'emailAddress') + end + + def merge_timestamp + timestamp = commit['committerTimestamp'] + + self.class.convert_timestamp(timestamp) + end + + def merge_commit + commit['id'] + end + + def created_at + self.class.convert_timestamp(created_date) + end + + private + + def commit + raw.fetch('commit', {}) + end + + def action + raw['action'] + end + + def comment_anchor + raw['commentAnchor'] + end + + def created_date + raw['createdDate'] + end + end + end +end diff --git a/lib/bitbucket_server/representation/base.rb b/lib/bitbucket_server/representation/base.rb new file mode 100644 index 00000000000..a1961bae6ef --- /dev/null +++ b/lib/bitbucket_server/representation/base.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class Base + attr_reader :raw + + def initialize(raw) + @raw = raw + end + + def self.decorate(entries) + entries.map { |entry| new(entry)} + end + + def self.convert_timestamp(time_usec) + Time.at(time_usec / 1000) if time_usec.is_a?(Integer) + end + end + end +end diff --git a/lib/bitbucket_server/representation/comment.rb b/lib/bitbucket_server/representation/comment.rb new file mode 100644 index 00000000000..99b97a3b181 --- /dev/null +++ b/lib/bitbucket_server/representation/comment.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + # A general comment with the structure: + # "comment": { + # "author": { + # "active": true, + # "displayName": "root", + # "emailAddress": "stanhu+bitbucket@gitlab.com", + # "id": 1, + # "links": { + # "self": [ + # { + # "href": "http://localhost:7990/users/root" + # } + # ] + # }, + # "name": "root", + # "slug": "root", + # "type": "NORMAL" + # } + # } + # } + class Comment < Representation::Base + attr_reader :parent_comment + + CommentNode = Struct.new(:raw_comments, :parent) + + def initialize(raw, parent_comment: nil) + super(raw) + + @parent_comment = parent_comment + end + + def id + raw_comment['id'] + end + + def author_username + author['displayName'] + end + + def author_email + author['emailAddress'] + end + + def note + raw_comment['text'] + end + + def created_at + self.class.convert_timestamp(created_date) + end + + def updated_at + self.class.convert_timestamp(created_date) + end + + # Bitbucket Server supports the ability to reply to any comment + # and created multiple threads. It represents these as a linked list + # of comments within comments. For example: + # + # "comments": [ + # { + # "author" : ... + # "comments": [ + # { + # "author": ... + # + # Since GitLab only supports a single thread, we flatten all these + # comments into a single discussion. + def comments + @comments ||= flatten_comments + end + + private + + # In order to provide context for each reply, we need to track + # the parent of each comment. This method works as follows: + # + # 1. Insert the root comment into the workset. The root element is the current note. + # 2. For each node in the workset: + # a. Examine if it has replies to that comment. If it does, + # insert that node into the workset. + # b. Parse that note into a Comment structure and add it to a flat list. + def flatten_comments + comments = raw_comment['comments'] + workset = + if comments + [CommentNode.new(comments, self)] + else + [] + end + + all_comments = [] + + until workset.empty? + node = workset.pop + parent = node.parent + + node.raw_comments.each do |comment| + new_comments = comment.delete('comments') + current_comment = Comment.new({ 'comment' => comment }, parent_comment: parent) + all_comments << current_comment + workset << CommentNode.new(new_comments, current_comment) if new_comments + end + end + + all_comments + end + + def raw_comment + raw.fetch('comment', {}) + end + + def author + raw_comment['author'] + end + + def created_date + raw_comment['createdDate'] + end + + def updated_date + raw_comment['updatedDate'] + end + end + end +end diff --git a/lib/bitbucket_server/representation/pull_request.rb b/lib/bitbucket_server/representation/pull_request.rb new file mode 100644 index 00000000000..c3e927d8de7 --- /dev/null +++ b/lib/bitbucket_server/representation/pull_request.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class PullRequest < Representation::Base + def author + raw.dig('author', 'user', 'name') + end + + def author_email + raw.dig('author', 'user', 'emailAddress') + end + + def description + raw['description'] + end + + def iid + raw['id'] + end + + def state + case raw['state'] + when 'MERGED' + 'merged' + when 'DECLINED' + 'closed' + else + 'opened' + end + end + + def merged? + state == 'merged' + end + + def created_at + self.class.convert_timestamp(created_date) + end + + def updated_at + self.class.convert_timestamp(updated_date) + end + + def title + raw['title'] + end + + def source_branch_name + raw.dig('fromRef', 'id') + end + + def source_branch_sha + raw.dig('fromRef', 'latestCommit') + end + + def target_branch_name + raw.dig('toRef', 'id') + end + + def target_branch_sha + raw.dig('toRef', 'latestCommit') + end + + private + + def created_date + raw['createdDate'] + end + + def updated_date + raw['updatedDate'] + end + end + end +end diff --git a/lib/bitbucket_server/representation/pull_request_comment.rb b/lib/bitbucket_server/representation/pull_request_comment.rb new file mode 100644 index 00000000000..a2b3873a397 --- /dev/null +++ b/lib/bitbucket_server/representation/pull_request_comment.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + # An inline comment with the following structure that identifies + # the part of the diff: + # + # "commentAnchor": { + # "diffType": "EFFECTIVE", + # "fileType": "TO", + # "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + # "line": 1, + # "lineType": "ADDED", + # "orphaned": false, + # "path": "CHANGELOG.md", + # "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + # } + # + # More details in https://docs.atlassian.com/bitbucket-server/rest/5.12.0/bitbucket-rest.html. + class PullRequestComment < Comment + def from_sha + comment_anchor['fromHash'] + end + + def to_sha + comment_anchor['toHash'] + end + + def to? + file_type == 'TO' + end + + def from? + file_type == 'FROM' + end + + def added? + line_type == 'ADDED' + end + + def removed? + line_type == 'REMOVED' + end + + # There are three line comment types: added, removed, or context. + # + # 1. An added type means a new line was inserted, so there is no old position. + # 2. A removed type means a line was removed, so there is no new position. + # 3. A context type means the line was unmodified, so there is both a + # old and new position. + def new_pos + return if removed? + return unless line_position + + line_position[1] + end + + def old_pos + return if added? + return unless line_position + + line_position[0] + end + + def file_path + comment_anchor.fetch('path') + end + + private + + def file_type + comment_anchor['fileType'] + end + + def line_type + comment_anchor['lineType'] + end + + # Each comment contains the following information about the diff: + # + # hunks: [ + # { + # segments: [ + # { + # "lines": [ + # { + # "commentIds": [ N ], + # "source": X, + # "destination": Y + # }, ... + # ] .... + # + # To determine the line position of a comment, we search all the lines + # entries until we find this comment ID. + def line_position + @line_position ||= diff_hunks.each do |hunk| + segments = hunk.fetch('segments', []) + segments.each do |segment| + lines = segment.fetch('lines', []) + lines.each do |line| + if line['commentIds']&.include?(id) + return [line['source'], line['destination']] + end + end + end + end + end + + def comment_anchor + raw.fetch('commentAnchor', {}) + end + + def diff + raw.fetch('diff', {}) + end + + def diff_hunks + diff.fetch('hunks', []) + end + end + end +end diff --git a/lib/bitbucket_server/representation/repo.rb b/lib/bitbucket_server/representation/repo.rb new file mode 100644 index 00000000000..6c494b79166 --- /dev/null +++ b/lib/bitbucket_server/representation/repo.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class Repo < Representation::Base + def initialize(raw) + super(raw) + end + + def project_key + raw.dig('project', 'key') + end + + def project_name + raw.dig('project', 'name') + end + + def slug + raw['slug'] + end + + def browse_url + # The JSON reponse contains an array of 1 element. Not sure if there + # are cases where multiple links would be provided. + raw.dig('links', 'self').first.fetch('href') + end + + def clone_url + raw['links']['clone'].find { |link| link['name'].starts_with?('http') }.fetch('href') + end + + def description + project['description'] + end + + def full_name + "#{project_name}/#{name}" + end + + def issues_enabled? + true + end + + def name + raw['name'] + end + + def valid? + raw['scmId'] == 'git' + end + + def visibility_level + if project['public'] + Gitlab::VisibilityLevel::PUBLIC + else + Gitlab::VisibilityLevel::PRIVATE + end + end + + def project + raw['project'] + end + + def to_s + full_name + end + end + end +end diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb index d3f66877672..36c85dec544 100644 --- a/lib/gitlab/background_migration.rb +++ b/lib/gitlab/background_migration.rb @@ -46,7 +46,11 @@ module Gitlab # arguments - The arguments to pass to the background migration's "perform" # method. def self.perform(class_name, arguments) - const_get(class_name).new.perform(*arguments) + migration_class_for(class_name).new.perform(*arguments) + end + + def self.migration_class_for(class_name) + const_get(class_name) end end end diff --git a/lib/gitlab/background_migration/remove_restricted_todos.rb b/lib/gitlab/background_migration/remove_restricted_todos.rb new file mode 100644 index 00000000000..68f3fa62170 --- /dev/null +++ b/lib/gitlab/background_migration/remove_restricted_todos.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +# rubocop:disable Style/Documentation + +module Gitlab + module BackgroundMigration + class RemoveRestrictedTodos + PRIVATE_FEATURE = 10 + PRIVATE_PROJECT = 0 + + class Project < ActiveRecord::Base + self.table_name = 'projects' + end + + class ProjectAuthorization < ActiveRecord::Base + self.table_name = 'project_authorizations' + end + + class ProjectFeature < ActiveRecord::Base + self.table_name = 'project_features' + end + + class Todo < ActiveRecord::Base + include EachBatch + + self.table_name = 'todos' + end + + class Issue < ActiveRecord::Base + include EachBatch + + self.table_name = 'issues' + end + + def perform(start_id, stop_id) + projects = Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)') + .where(id: start_id..stop_id) + + projects.each do |project| + remove_confidential_issue_todos(project.id) + + if project.visibility_level == PRIVATE_PROJECT + remove_non_members_todos(project.id) + else + remove_restricted_features_todos(project.id) + end + end + end + + private + + def remove_non_members_todos(project_id) + Todo.where(project_id: project_id) + .where('user_id NOT IN (?)', authorized_users(project_id)) + .each_batch(of: 5000) do |batch| + batch.delete_all + end + end + + def remove_confidential_issue_todos(project_id) + # min access level to access a confidential issue is reporter + min_reporters = authorized_users(project_id) + .select(:user_id) + .where('access_level >= ?', 20) + + confidential_issues = Issue.select(:id, :author_id).where(confidential: true, project_id: project_id) + confidential_issues.each_batch(of: 100) do |batch| + batch.each do |issue| + assigned_users = IssueAssignee.select(:user_id).where(issue_id: issue.id) + + todos = Todo.where(target_type: 'Issue', target_id: issue.id) + .where('user_id NOT IN (?)', min_reporters) + .where('user_id NOT IN (?)', assigned_users) + todos = todos.where('user_id != ?', issue.author_id) if issue.author_id + + todos.delete_all + end + end + end + + def remove_restricted_features_todos(project_id) + ProjectFeature.where(project_id: project_id).each do |project_features| + target_types = [] + target_types << 'Issue' if private?(project_features.issues_access_level) + target_types << 'MergeRequest' if private?(project_features.merge_requests_access_level) + target_types << 'Commit' if private?(project_features.repository_access_level) + + next if target_types.empty? + + Todo.where(project_id: project_id) + .where('user_id NOT IN (?)', authorized_users(project_id)) + .where(target_type: target_types) + .delete_all + end + end + + def private?(feature_level) + feature_level == PRIVATE_FEATURE + end + + def authorized_users(project_id) + ProjectAuthorization.select(:user_id).where(project_id: project_id) + end + end + end +end diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb new file mode 100644 index 00000000000..268d21a77d1 --- /dev/null +++ b/lib/gitlab/bitbucket_server_import/importer.rb @@ -0,0 +1,327 @@ +module Gitlab + module BitbucketServerImport + class Importer + include Gitlab::ShellAdapter + attr_reader :recover_missing_commits + attr_reader :project, :project_key, :repository_slug, :client, :errors, :users + + REMOTE_NAME = 'bitbucket_server'.freeze + BATCH_SIZE = 100 + + TempBranch = Struct.new(:name, :sha) + + def self.imports_repository? + true + end + + def self.refmap + [:heads, :tags, '+refs/pull-requests/*/to:refs/merge-requests/*/head'] + end + + # Unlike GitHub, you can't grab the commit SHAs for pull requests that + # have been closed but not merged even though Bitbucket has these + # commits internally. We can recover these pull requests by creating a + # branch with the Bitbucket REST API, but by default we turn this + # behavior off. + def initialize(project, recover_missing_commits: false) + @project = project + @recover_missing_commits = recover_missing_commits + @project_key = project.import_data.data['project_key'] + @repository_slug = project.import_data.data['repo_slug'] + @client = BitbucketServer::Client.new(project.import_data.credentials) + @formatter = Gitlab::ImportFormatter.new + @errors = [] + @users = {} + @temp_branches = [] + end + + def execute + import_repository + import_pull_requests + delete_temp_branches + handle_errors + + true + end + + private + + def handle_errors + return unless errors.any? + + project.update_column(:import_error, { + message: 'The remote data could not be fully imported.', + errors: errors + }.to_json) + end + + def gitlab_user_id(email) + find_user_id(email) || project.creator_id + end + + def find_user_id(email) + return nil unless email + + return users[email] if users.key?(email) + + user = User.find_by_any_email(email, confirmed: true) + users[email] = user&.id + + user&.id + end + + def repo + @repo ||= client.repo(project_key, repository_slug) + end + + def sha_exists?(sha) + project.repository.commit(sha) + end + + def temp_branch_name(pull_request, suffix) + "gitlab/import/pull-request/#{pull_request.iid}/#{suffix}" + end + + # This method restores required SHAs that GitLab needs to create diffs + # into branch names as the following: + # + # gitlab/import/pull-request/N/{to,from} + def restore_branches(pull_requests) + shas_to_restore = [] + + pull_requests.each do |pull_request| + shas_to_restore << TempBranch.new(temp_branch_name(pull_request, :from), + pull_request.source_branch_sha) + shas_to_restore << TempBranch.new(temp_branch_name(pull_request, :to), + pull_request.target_branch_sha) + end + + # Create the branches on the Bitbucket Server first + created_branches = restore_branch_shas(shas_to_restore) + + @temp_branches += created_branches + # Now sync the repository so we get the new branches + import_repository unless created_branches.empty? + end + + def restore_branch_shas(shas_to_restore) + shas_to_restore.each_with_object([]) do |temp_branch, branches_created| + branch_name = temp_branch.name + sha = temp_branch.sha + + next if sha_exists?(sha) + + begin + client.create_branch(project_key, repository_slug, branch_name, sha) + branches_created << temp_branch + rescue BitbucketServer::Connection::ConnectionError => e + Rails.logger.warn("BitbucketServerImporter: Unable to recreate branch for SHA #{sha}: #{e}") + end + end + end + + def import_repository + project.ensure_repository + project.repository.fetch_as_mirror(project.import_url, refmap: self.class.refmap, remote_name: REMOTE_NAME) + rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e + # Expire cache to prevent scenarios such as: + # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true + # 2. Retried import, repo is broken or not imported but +exists?+ still returns true + project.repository.expire_content_cache if project.repository_exists? + + raise e.message + end + + # Bitbucket Server keeps tracks of references for open pull requests in + # refs/heads/pull-requests, but closed and merged requests get moved + # into hidden internal refs under stash-refs/pull-requests. Unless the + # SHAs involved are at the tip of a branch or tag, there is no way to + # retrieve the server for those commits. + # + # To avoid losing history, we use the Bitbucket API to re-create the branch + # on the remote server. Then we have to issue a `git fetch` to download these + # branches. + def import_pull_requests + pull_requests = client.pull_requests(project_key, repository_slug).to_a + + # Creating branches on the server and fetching the newly-created branches + # may take a number of network round-trips. Do this in batches so that we can + # avoid doing a git fetch for every new branch. + pull_requests.each_slice(BATCH_SIZE) do |batch| + restore_branches(batch) if recover_missing_commits + + batch.each do |pull_request| + begin + import_bitbucket_pull_request(pull_request) + rescue StandardError => e + errors << { type: :pull_request, iid: pull_request.iid, errors: e.message, trace: e.backtrace.join("\n"), raw_response: pull_request.raw } + end + end + end + end + + def delete_temp_branches + @temp_branches.each do |branch| + begin + client.delete_branch(project_key, repository_slug, branch.name, branch.sha) + project.repository.delete_branch(branch.name) + rescue BitbucketServer::Connection::ConnectionError => e + @errors << { type: :delete_temp_branches, branch_name: branch.name, errors: e.message } + end + end + end + + def import_bitbucket_pull_request(pull_request) + description = '' + description += @formatter.author_line(pull_request.author) unless find_user_id(pull_request.author_email) + description += pull_request.description if pull_request.description + + source_branch_sha = pull_request.source_branch_sha + target_branch_sha = pull_request.target_branch_sha + author_id = gitlab_user_id(pull_request.author_email) + + attributes = { + iid: pull_request.iid, + title: pull_request.title, + description: description, + source_project: project, + source_branch: Gitlab::Git.ref_name(pull_request.source_branch_name), + source_branch_sha: source_branch_sha, + target_project: project, + target_branch: Gitlab::Git.ref_name(pull_request.target_branch_name), + target_branch_sha: target_branch_sha, + state: pull_request.state, + author_id: author_id, + assignee_id: nil, + created_at: pull_request.created_at, + updated_at: pull_request.updated_at + } + + merge_request = project.merge_requests.create!(attributes) + import_pull_request_comments(pull_request, merge_request) if merge_request.persisted? + end + + def import_pull_request_comments(pull_request, merge_request) + comments, other_activities = client.activities(project_key, repository_slug, pull_request.iid).partition(&:comment?) + + merge_event = other_activities.find(&:merge_event?) + import_merge_event(merge_request, merge_event) if merge_event + + inline_comments, pr_comments = comments.partition(&:inline_comment?) + + import_inline_comments(inline_comments.map(&:comment), merge_request) + import_standalone_pr_comments(pr_comments.map(&:comment), merge_request) + end + + def import_merge_event(merge_request, merge_event) + committer = merge_event.committer_email + + user_id = gitlab_user_id(committer) + timestamp = merge_event.merge_timestamp + merge_request.update({ merge_commit_sha: merge_event.merge_commit }) + metric = MergeRequest::Metrics.find_or_initialize_by(merge_request: merge_request) + metric.update(merged_by_id: user_id, merged_at: timestamp) + end + + def import_inline_comments(inline_comments, merge_request) + inline_comments.each do |comment| + position = build_position(merge_request, comment) + parent = create_diff_note(merge_request, comment, position) + + next unless parent&.persisted? + + discussion_id = parent.discussion_id + + comment.comments.each do |reply| + create_diff_note(merge_request, reply, position, discussion_id) + end + end + end + + def create_diff_note(merge_request, comment, position, discussion_id = nil) + attributes = pull_request_comment_attributes(comment) + attributes.merge!(position: position, type: 'DiffNote') + attributes[:discussion_id] = discussion_id if discussion_id + + note = merge_request.notes.build(attributes) + + if note.valid? + note.save + return note + end + + # Bitbucket Server supports the ability to comment on any line, not just the + # line in the diff. If we can't add the note as a DiffNote, fallback to creating + # a regular note. + create_fallback_diff_note(merge_request, comment, position) + rescue StandardError => e + errors << { type: :pull_request, id: comment.id, errors: e.message } + nil + end + + def create_fallback_diff_note(merge_request, comment, position) + attributes = pull_request_comment_attributes(comment) + note = "*Comment on" + + note += " #{position.old_path}:#{position.old_line} -->" if position.old_line + note += " #{position.new_path}:#{position.new_line}" if position.new_line + note += "*\n\n#{comment.note}" + + attributes[:note] = note + merge_request.notes.create!(attributes) + end + + def build_position(merge_request, pr_comment) + params = { + diff_refs: merge_request.diff_refs, + old_path: pr_comment.file_path, + new_path: pr_comment.file_path, + old_line: pr_comment.old_pos, + new_line: pr_comment.new_pos + } + + Gitlab::Diff::Position.new(params) + end + + def import_standalone_pr_comments(pr_comments, merge_request) + pr_comments.each do |comment| + begin + merge_request.notes.create!(pull_request_comment_attributes(comment)) + + comment.comments.each do |replies| + merge_request.notes.create!(pull_request_comment_attributes(replies)) + end + rescue StandardError => e + errors << { type: :pull_request, iid: comment.id, errors: e.message } + end + end + end + + def pull_request_comment_attributes(comment) + author = find_user_id(comment.author_email) + note = '' + + unless author + author = project.creator_id + note = "*By #{comment.author_username} (#{comment.author_email})*\n\n" + end + + note += + # Provide some context for replying + if comment.parent_comment + "> #{comment.parent_comment.note.truncate(80)}\n\n#{comment.note}" + else + comment.note + end + + { + project: project, + note: note, + author_id: author, + created_at: comment.created_at, + updated_at: comment.updated_at + } + end + end + end +end diff --git a/lib/gitlab/bitbucket_server_import/project_creator.rb b/lib/gitlab/bitbucket_server_import/project_creator.rb new file mode 100644 index 00000000000..35e8cd7e0ab --- /dev/null +++ b/lib/gitlab/bitbucket_server_import/project_creator.rb @@ -0,0 +1,36 @@ +module Gitlab + module BitbucketServerImport + class ProjectCreator + attr_reader :project_key, :repo_slug, :repo, :name, :namespace, :current_user, :session_data + + def initialize(project_key, repo_slug, repo, name, namespace, current_user, session_data) + @project_key = project_key + @repo_slug = repo_slug + @repo = repo + @name = name + @namespace = namespace + @current_user = current_user + @session_data = session_data + end + + def execute + ::Projects::CreateService.new( + current_user, + name: name, + path: name, + description: repo.description, + namespace_id: namespace.id, + visibility_level: repo.visibility_level, + import_type: 'bitbucket_server', + import_source: repo.browse_url, + import_url: repo.clone_url, + import_data: { + credentials: session_data, + data: { project_key: project_key, repo_slug: repo_slug } + }, + skip_wiki: true + ).execute + end + end + end +end diff --git a/lib/gitlab/checks/lfs_integrity.rb b/lib/gitlab/checks/lfs_integrity.rb index f0e5773ec3c..b816a8f00cd 100644 --- a/lib/gitlab/checks/lfs_integrity.rb +++ b/lib/gitlab/checks/lfs_integrity.rb @@ -1,8 +1,6 @@ module Gitlab module Checks class LfsIntegrity - REV_LIST_OBJECT_LIMIT = 2_000 - def initialize(project, newrev) @project = project @newrev = newrev @@ -11,7 +9,8 @@ module Gitlab def objects_missing? return false unless @newrev && @project.lfs_enabled? - new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev).new_pointers(object_limit: REV_LIST_OBJECT_LIMIT) + new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev) + .new_pointers(object_limit: ::Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT) return false unless new_lfs_pointers.present? diff --git a/lib/gitlab/cleanup/remote_uploads.rb b/lib/gitlab/cleanup/remote_uploads.rb new file mode 100644 index 00000000000..45a5aea4fcd --- /dev/null +++ b/lib/gitlab/cleanup/remote_uploads.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +module Gitlab + module Cleanup + class RemoteUploads + attr_reader :logger + + BATCH_SIZE = 100 + + def initialize(logger: nil) + @logger = logger || Rails.logger + end + + def run!(dry_run: false) + unless configuration.enabled + logger.warn "Object storage not enabled. Exit".color(:yellow) + + return + end + + logger.info "Looking for orphaned remote uploads to remove#{'. Dry run' if dry_run}..." + + each_orphan_file do |file| + info = if dry_run + "Can be moved to lost and found: #{file.key}" + else + new_path = move_to_lost_and_found(file) + "Moved to lost and found: #{file.key} -> #{new_path}" + end + + logger.info(info) + end + end + + private + + def each_orphan_file + # we want to skip files already moved to lost_and_found directory + lost_dir_match = "^#{lost_and_found_dir}\/" + + remote_directory.files.each_slice(BATCH_SIZE) do |remote_files| + remote_files.reject! { |file| file.key.match(/#{lost_dir_match}/) } + file_paths = remote_files.map(&:key) + tracked_paths = Upload + .where(store: ObjectStorage::Store::REMOTE, path: file_paths) + .pluck(:path) + + remote_files.reject! { |file| tracked_paths.include?(file.key) } + remote_files.each do |file| + yield file + end + end + end + + def move_to_lost_and_found(file) + new_path = "#{lost_and_found_dir}/#{file.key}" + + file.copy(configuration['remote_directory'], new_path) + file.destroy + + new_path + end + + def lost_and_found_dir + 'lost_and_found' + end + + def remote_directory + connection.directories.get(configuration['remote_directory']) + end + + def connection + ::Fog::Storage.new(configuration['connection'].symbolize_keys) + end + + def configuration + Gitlab.config.uploads.object_store + end + end + end +end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 4fe5b4cc835..f39b3b6eb5b 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -979,8 +979,8 @@ into similar problems in the future (e.g. when new tables are created). # To not overload the worker too much we enforce a minimum interval both # when scheduling and performing jobs. - if delay_interval < BackgroundMigrationWorker::MIN_INTERVAL - delay_interval = BackgroundMigrationWorker::MIN_INTERVAL + if delay_interval < BackgroundMigrationWorker.minimum_interval + delay_interval = BackgroundMigrationWorker.minimum_interval end model_class.each_batch(of: batch_size) do |relation, index| diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 73151e4a4c5..de189ac6dfc 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -19,6 +19,7 @@ module Gitlab GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE ].freeze SEARCH_CONTEXT_LINES = 3 + REV_LIST_COMMIT_LIMIT = 2_000 # In https://gitlab.com/gitlab-org/gitaly/merge_requests/698 # We copied these two prefixes into gitaly-go, so don't change these # or things will break! (REBASE_WORKTREE_PREFIX and SQUASH_WORKTREE_PREFIX) @@ -380,6 +381,16 @@ module Gitlab end end + def new_blobs(newrev) + return [] if newrev == ::Gitlab::Git::BLANK_SHA + + strong_memoize("new_blobs_#{newrev}") do + wrapped_gitaly_errors do + gitaly_ref_client.list_new_blobs(newrev, REV_LIST_COMMIT_LIMIT) + end + end + end + def count_commits(options) options = process_count_commits_options(options.dup) diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb index 41d58192818..8acc22e809e 100644 --- a/lib/gitlab/gitaly_client/ref_service.rb +++ b/lib/gitlab/gitaly_client/ref_service.rb @@ -82,6 +82,23 @@ module Gitlab commits end + def list_new_blobs(newrev, limit = 0) + request = Gitaly::ListNewBlobsRequest.new( + repository: @gitaly_repo, + commit_id: newrev, + limit: limit + ) + + response = GitalyClient + .call(@storage, :ref_service, :list_new_blobs, request, timeout: GitalyClient.medium_timeout) + + response.flat_map do |msg| + # Returns an Array of Gitaly::NewBlobObject objects + # Available methods are: #size, #oid and #path + msg.new_blob_objects + end + end + def count_tag_names tag_names.count end diff --git a/lib/gitlab/import_sources.rb b/lib/gitlab/import_sources.rb index 45816bee176..f7f5c5787f6 100644 --- a/lib/gitlab/import_sources.rb +++ b/lib/gitlab/import_sources.rb @@ -9,15 +9,16 @@ module Gitlab # We exclude `bare_repository` here as it has no import class associated ImportTable = [ - ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter), - ImportSource.new('bitbucket', 'Bitbucket', Gitlab::BitbucketImport::Importer), - ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer), - ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer), - ImportSource.new('fogbugz', 'FogBugz', Gitlab::FogbugzImport::Importer), - ImportSource.new('git', 'Repo by URL', nil), - ImportSource.new('gitlab_project', 'GitLab export', Gitlab::ImportExport::Importer), - ImportSource.new('gitea', 'Gitea', Gitlab::LegacyGithubImport::Importer), - ImportSource.new('manifest', 'Manifest file', nil) + ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter), + ImportSource.new('bitbucket', 'Bitbucket Cloud', Gitlab::BitbucketImport::Importer), + ImportSource.new('bitbucket_server', 'Bitbucket Server', Gitlab::BitbucketServerImport::Importer), + ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer), + ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer), + ImportSource.new('fogbugz', 'FogBugz', Gitlab::FogbugzImport::Importer), + ImportSource.new('git', 'Repo by URL', nil), + ImportSource.new('gitlab_project', 'GitLab export', Gitlab::ImportExport::Importer), + ImportSource.new('gitea', 'Gitea', Gitlab::LegacyGithubImport::Importer), + ImportSource.new('manifest', 'Manifest file', nil) ].freeze class << self diff --git a/lib/gitlab/kubernetes/config_map.rb b/lib/gitlab/kubernetes/config_map.rb index 8a8a59a9cd4..9e55dae137c 100644 --- a/lib/gitlab/kubernetes/config_map.rb +++ b/lib/gitlab/kubernetes/config_map.rb @@ -1,15 +1,15 @@ module Gitlab module Kubernetes class ConfigMap - def initialize(name, values = "") + def initialize(name, files) @name = name - @values = values + @files = files end def generate resource = ::Kubeclient::Resource.new resource.metadata = metadata - resource.data = { values: values } + resource.data = files resource end @@ -19,7 +19,7 @@ module Gitlab private - attr_reader :name, :values + attr_reader :name, :files def metadata { diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb index c4de9a398cc..d65374cc23b 100644 --- a/lib/gitlab/kubernetes/helm/api.rb +++ b/lib/gitlab/kubernetes/helm/api.rb @@ -9,7 +9,7 @@ module Gitlab def install(command) namespace.ensure_exists! - create_config_map(command) if command.config_map? + create_config_map(command) kubeclient.create_pod(command.pod_resource) end diff --git a/lib/gitlab/kubernetes/helm/base_command.rb b/lib/gitlab/kubernetes/helm/base_command.rb index f9ebe53d6af..afcfd109de0 100644 --- a/lib/gitlab/kubernetes/helm/base_command.rb +++ b/lib/gitlab/kubernetes/helm/base_command.rb @@ -1,13 +1,7 @@ module Gitlab module Kubernetes module Helm - class BaseCommand - attr_reader :name - - def initialize(name) - @name = name - end - + module BaseCommand def pod_resource Gitlab::Kubernetes::Helm::Pod.new(self, namespace).generate end @@ -24,16 +18,32 @@ module Gitlab HEREDOC end - def config_map? - false - end - def pod_name "install-#{name}" end + def config_map_resource + Gitlab::Kubernetes::ConfigMap.new(name, files).generate + end + + def file_names + files.keys + end + + def name + raise "Not implemented" + end + + def files + raise "Not implemented" + end + private + def files_dir + "/data/helm/#{name}/config" + end + def namespace Gitlab::Kubernetes::Helm::NAMESPACE end diff --git a/lib/gitlab/kubernetes/helm/certificate.rb b/lib/gitlab/kubernetes/helm/certificate.rb new file mode 100644 index 00000000000..598714e0874 --- /dev/null +++ b/lib/gitlab/kubernetes/helm/certificate.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +module Gitlab + module Kubernetes + module Helm + class Certificate + INFINITE_EXPIRY = 1000.years + SHORT_EXPIRY = 30.minutes + + attr_reader :key, :cert + + def key_string + @key.to_s + end + + def cert_string + @cert.to_pem + end + + def self.from_strings(key_string, cert_string) + key = OpenSSL::PKey::RSA.new(key_string) + cert = OpenSSL::X509::Certificate.new(cert_string) + new(key, cert) + end + + def self.generate_root + _issue(signed_by: nil, expires_in: INFINITE_EXPIRY, certificate_authority: true) + end + + def issue(expires_in: SHORT_EXPIRY) + self.class._issue(signed_by: self, expires_in: expires_in, certificate_authority: false) + end + + private + + def self._issue(signed_by:, expires_in:, certificate_authority:) + key = OpenSSL::PKey::RSA.new(4096) + public_key = key.public_key + + subject = OpenSSL::X509::Name.parse("/C=US") + + cert = OpenSSL::X509::Certificate.new + cert.subject = subject + + cert.issuer = signed_by&.cert&.subject || subject + + cert.not_before = Time.now + cert.not_after = expires_in.from_now + cert.public_key = public_key + cert.serial = 0x0 + cert.version = 2 + + if certificate_authority + extension_factory = OpenSSL::X509::ExtensionFactory.new + extension_factory.subject_certificate = cert + extension_factory.issuer_certificate = cert + cert.add_extension(extension_factory.create_extension('subjectKeyIdentifier', 'hash')) + cert.add_extension(extension_factory.create_extension('basicConstraints', 'CA:TRUE', true)) + cert.add_extension(extension_factory.create_extension('keyUsage', 'cRLSign,keyCertSign', true)) + end + + cert.sign(signed_by&.key || key, OpenSSL::Digest::SHA256.new) + + new(key, cert) + end + + def initialize(key, cert) + @key = key + @cert = cert + end + end + end + end +end diff --git a/lib/gitlab/kubernetes/helm/init_command.rb b/lib/gitlab/kubernetes/helm/init_command.rb index a02e64561f6..a4546509515 100644 --- a/lib/gitlab/kubernetes/helm/init_command.rb +++ b/lib/gitlab/kubernetes/helm/init_command.rb @@ -1,7 +1,16 @@ module Gitlab module Kubernetes module Helm - class InitCommand < BaseCommand + class InitCommand + include BaseCommand + + attr_reader :name, :files + + def initialize(name:, files:) + @name = name + @files = files + end + def generate_script super + [ init_helm_command @@ -11,7 +20,12 @@ module Gitlab private def init_helm_command - "helm init >/dev/null" + tls_flags = "--tiller-tls" \ + " --tiller-tls-verify --tls-ca-cert #{files_dir}/ca.pem" \ + " --tiller-tls-cert #{files_dir}/cert.pem" \ + " --tiller-tls-key #{files_dir}/key.pem" + + "helm init #{tls_flags} >/dev/null" end end end diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb index d2133a6d65b..9672f80687e 100644 --- a/lib/gitlab/kubernetes/helm/install_command.rb +++ b/lib/gitlab/kubernetes/helm/install_command.rb @@ -1,14 +1,16 @@ module Gitlab module Kubernetes module Helm - class InstallCommand < BaseCommand - attr_reader :name, :chart, :version, :repository, :values + class InstallCommand + include BaseCommand - def initialize(name, chart:, values:, version: nil, repository: nil) + attr_reader :name, :files, :chart, :version, :repository + + def initialize(name:, chart:, files:, version: nil, repository: nil) @name = name @chart = chart @version = version - @values = values + @files = files @repository = repository end @@ -20,14 +22,6 @@ module Gitlab ].compact.join("\n") end - def config_map? - true - end - - def config_map_resource - Gitlab::Kubernetes::ConfigMap.new(name, values).generate - end - private def init_command @@ -39,14 +33,25 @@ module Gitlab end def script_command - <<~HEREDOC - helm install #{chart} --name #{name}#{optional_version_flag} --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE} -f /data/helm/#{name}/config/values.yaml >/dev/null - HEREDOC + init_flags = "--name #{name}#{optional_tls_flags}#{optional_version_flag}" \ + " --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE}" \ + " -f /data/helm/#{name}/config/values.yaml" + + "helm install #{chart} #{init_flags} >/dev/null\n" end def optional_version_flag " --version #{version}" if version end + + def optional_tls_flags + return unless files.key?(:'ca.pem') + + " --tls" \ + " --tls-ca-cert #{files_dir}/ca.pem" \ + " --tls-cert #{files_dir}/cert.pem" \ + " --tls-key #{files_dir}/key.pem" + end end end end diff --git a/lib/gitlab/kubernetes/helm/pod.rb b/lib/gitlab/kubernetes/helm/pod.rb index 1e12299eefd..6e5d3388405 100644 --- a/lib/gitlab/kubernetes/helm/pod.rb +++ b/lib/gitlab/kubernetes/helm/pod.rb @@ -10,10 +10,8 @@ module Gitlab def generate spec = { containers: [container_specification], restartPolicy: 'Never' } - if command.config_map? - spec[:volumes] = volumes_specification - spec[:containers][0][:volumeMounts] = volume_mounts_specification - end + spec[:volumes] = volumes_specification + spec[:containers][0][:volumeMounts] = volume_mounts_specification ::Kubeclient::Resource.new(metadata: metadata, spec: spec) end @@ -61,7 +59,7 @@ module Gitlab name: 'configuration-volume', configMap: { name: "values-content-configuration-#{command.name}", - items: [{ key: 'values', path: 'values.yaml' }] + items: command.file_names.map { |name| { key: name, path: name } } } } ] diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index e1a958c508a..0f26fcfe8cb 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -99,5 +99,9 @@ module Gitlab ) }mx end + + def jira_transition_id_regex + @jira_transition_id_regex ||= /\d+/ + end end end diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index a2feb074b1d..c8a8863443e 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -116,6 +116,16 @@ namespace :gitlab do end end + desc 'GitLab | Cleanup | Clean orphan remote upload files that do not exist in the db' + task remote_upload_files: :environment do + cleaner = Gitlab::Cleanup::RemoteUploads.new(logger: logger) + cleaner.run!(dry_run: dry_run?) + + if dry_run? + logger.info "To cleanup these files run this command with DRY_RUN=false".color(:yellow) + end + end + def remove? ENV['REMOVE'] == 'true' end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a414f0a90cc..bec60cf592a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16,6 +16,9 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +msgid " Status" +msgstr "" + msgid "%d changed file" msgid_plural "%d changed files" msgstr[0] "" @@ -808,6 +811,9 @@ msgstr "" msgid "Below you will find all the groups that are public." msgstr "" +msgid "Bitbucket Server Import" +msgstr "" + msgid "Bitbucket import" msgstr "" @@ -984,9 +990,6 @@ msgstr "" msgid "CI/CD settings" msgstr "" -msgid "CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery." -msgstr "" - msgid "CICD|Auto DevOps" msgstr "" @@ -999,22 +1002,13 @@ msgstr "" msgid "CICD|Continuous deployment to production" msgstr "" -msgid "CICD|Deployment strategy" -msgstr "" - -msgid "CICD|Deployment strategy needs a domain name to work correctly." -msgstr "" - -msgid "CICD|Disable Auto DevOps" -msgstr "" - -msgid "CICD|Enable Auto DevOps" +msgid "CICD|Default to Auto DevOps pipeline" msgstr "" -msgid "CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}." +msgid "CICD|Deployment strategy" msgstr "" -msgid "CICD|Instance default (%{state})" +msgid "CICD|Deployment strategy needs a domain name to work correctly." msgstr "" msgid "CICD|Jobs" @@ -1023,12 +1017,15 @@ msgstr "" msgid "CICD|Learn more about Auto DevOps" msgstr "" -msgid "CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project." +msgid "CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found." msgstr "" msgid "CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages." msgstr "" +msgid "CICD|instance enabled" +msgstr "" + msgid "Callback URL" msgstr "" @@ -1933,6 +1930,9 @@ msgstr "" msgid "Cron syntax" msgstr "" +msgid "Current Branch" +msgstr "" + msgid "CurrentUser|Profile" msgstr "" @@ -2316,6 +2316,9 @@ msgstr "" msgid "Ends at (UTC)" msgstr "" +msgid "Enter in your Bitbucket Server URL and personal access token below" +msgstr "" + msgid "Environments" msgstr "" @@ -2409,6 +2412,9 @@ msgstr "" msgid "Error loading branch data. Please try again." msgstr "" +msgid "Error loading branches." +msgstr "" + msgid "Error loading last commit." msgstr "" @@ -2618,6 +2624,9 @@ msgstr "" msgid "From Bitbucket" msgstr "" +msgid "From Bitbucket Server" +msgstr "" + msgid "From FogBugz" msgstr "" @@ -2902,18 +2911,39 @@ msgstr "" msgid "ID" msgstr "" +msgid "IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation." +msgstr "" + +msgid "IDE|Back" +msgstr "" + +msgid "IDE|Client side evaluation" +msgstr "" + msgid "IDE|Commit" msgstr "" msgid "IDE|Edit" msgstr "" -msgid "IDE|Go back" +msgid "IDE|Get started with Live Preview" +msgstr "" + +msgid "IDE|Go to project" +msgstr "" + +msgid "IDE|Live Preview" msgstr "" msgid "IDE|Open in file view" msgstr "" +msgid "IDE|Preview your web application using Web IDE client-side evaluation." +msgstr "" + +msgid "IDE|Refresh preview" +msgstr "" + msgid "IDE|Review" msgstr "" @@ -2974,6 +3004,9 @@ msgstr "" msgid "Import projects from Bitbucket" msgstr "" +msgid "Import projects from Bitbucket Server" +msgstr "" + msgid "Import projects from FogBugz" msgstr "" @@ -2983,6 +3016,9 @@ msgstr "" msgid "Import projects from Google Code" msgstr "" +msgid "Import repositories from Bitbucket Server" +msgstr "" + msgid "Import repositories from GitHub" msgstr "" @@ -3219,9 +3255,15 @@ msgstr "" msgid "List available repositories" msgstr "" +msgid "List your Bitbucket Server repositories" +msgstr "" + msgid "List your GitHub repositories" msgstr "" +msgid "Live preview" +msgstr "" + msgid "Loading the GitLab IDE..." msgstr "" @@ -3255,6 +3297,9 @@ msgstr "" msgid "Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki." msgstr "" +msgid "Manage Web IDE features" +msgstr "" + msgid "Manage access" msgstr "" @@ -3566,6 +3611,9 @@ msgstr "" msgid "No assignee" msgstr "" +msgid "No branches found" +msgstr "" + msgid "No changes" msgstr "" @@ -3644,6 +3692,9 @@ msgstr "" msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token." msgstr "" +msgid "Notes|Are you sure you want to cancel creating this comment?" +msgstr "" + msgid "Notification events" msgstr "" @@ -4067,9 +4118,15 @@ msgstr "" msgid "Profiles|Add key" msgstr "" +msgid "Profiles|Add status emoji" +msgstr "" + msgid "Profiles|Change username" msgstr "" +msgid "Profiles|Clear status" +msgstr "" + msgid "Profiles|Current path: %{path}" msgstr "" @@ -4097,7 +4154,7 @@ msgstr "" msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?" msgstr "" -msgid "Profiles|This emoji and message will appear on your profile and throughout the interface. The message can contain emoji codes, too." +msgid "Profiles|This emoji and message will appear on your profile and throughout the interface." msgstr "" msgid "Profiles|Type your %{confirmationValue} to confirm:" @@ -4115,6 +4172,9 @@ msgstr "" msgid "Profiles|Username successfully changed" msgstr "" +msgid "Profiles|What's your status?" +msgstr "" + msgid "Profiles|You don't have access to delete this user." msgstr "" @@ -4124,6 +4184,9 @@ msgstr "" msgid "Profiles|Your account is currently an owner in these groups:" msgstr "" +msgid "Profiles|Your status" +msgstr "" + msgid "Profiles|e.g. My MacBook key" msgstr "" @@ -4594,12 +4657,39 @@ msgstr "" msgid "Search milestones" msgstr "" +msgid "Search or jump to…" +msgstr "" + msgid "Search project" msgstr "" msgid "Search users" msgstr "" +msgid "SearchAutocomplete|All GitLab" +msgstr "" + +msgid "SearchAutocomplete|Issues I've created" +msgstr "" + +msgid "SearchAutocomplete|Issues assigned to me" +msgstr "" + +msgid "SearchAutocomplete|Merge requests I've created" +msgstr "" + +msgid "SearchAutocomplete|Merge requests assigned to me" +msgstr "" + +msgid "SearchAutocomplete|in all GitLab" +msgstr "" + +msgid "SearchAutocomplete|in this group" +msgstr "" + +msgid "SearchAutocomplete|in this project" +msgstr "" + msgid "Seconds before reseting failure information" msgstr "" @@ -5685,7 +5775,7 @@ msgstr "" msgid "Users" msgstr "" -msgid "User|Current Status" +msgid "User|Current status" msgstr "" msgid "Variables" @@ -5976,9 +6066,6 @@ msgstr "" msgid "You cannot write to this read-only GitLab instance." msgstr "" -msgid "You do not have any assigned merge requests" -msgstr "" - msgid "You don't have any applications" msgstr "" @@ -5988,9 +6075,6 @@ msgstr "" msgid "You have no permissions" msgstr "" -msgid "You have not created any merge requests" -msgstr "" - msgid "You have reached your project limit" msgstr "" @@ -6131,6 +6215,9 @@ msgstr "" msgid "here" msgstr "" +msgid "https://your-bitbucket-server" +msgstr "" + msgid "import flow" msgstr "" diff --git a/package.json b/package.json index 4e5cf05f49b..975dd2619d7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "chart.js": "1.0.2", "classlist-polyfill": "^1.2.0", "clipboard": "^1.7.1", + "codesandbox-api": "^0.0.18", "compression-webpack-plugin": "^1.1.11", "core-js": "^2.4.1", "cropper": "^2.3.0", @@ -80,6 +81,7 @@ "sanitize-html": "^1.16.1", "select2": "3.5.2-browserify", "sha1": "^1.1.1", + "smooshpack": "^0.0.48", "sortablejs": "^1.7.0", "sql.js": "^0.4.0", "stickyfilljs": "^2.0.5", diff --git a/public/404.html b/public/404.html index 08f328da542..68b4ab0bb34 100644 --- a/public/404.html +++ b/public/404.html @@ -66,8 +66,10 @@ </head> <body> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" + <a href="/"> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /> + </a> <h1> 404 </h1> diff --git a/public/422.html b/public/422.html index a67dcd02200..a931e923efb 100644 --- a/public/422.html +++ b/public/422.html @@ -66,8 +66,10 @@ </head> <body> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" + <a href="/"> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /> + </a> <h1> 422 </h1> diff --git a/public/500.html b/public/500.html index 7091d14dfc4..df7b22dc9ef 100644 --- a/public/500.html +++ b/public/500.html @@ -66,8 +66,10 @@ </head> <body> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" + <a href="/"> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /> + </a> <h1> 500 </h1> diff --git a/public/502.html b/public/502.html index 82afd273248..77835767fa6 100644 --- a/public/502.html +++ b/public/502.html @@ -66,8 +66,10 @@ </head> <body> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" + <a href="/"> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /> + </a> <h1> 502 </h1> diff --git a/public/503.html b/public/503.html index f1486bc3e84..ee2da9b1313 100644 --- a/public/503.html +++ b/public/503.html @@ -66,8 +66,10 @@ </head> <body> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" + <a href="/"> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /> + </a> <h1> 503 </h1> diff --git a/qa/qa/factory/resource/kubernetes_cluster.rb b/qa/qa/factory/resource/kubernetes_cluster.rb index 1c9e5f94b22..ef2ea72b170 100644 --- a/qa/qa/factory/resource/kubernetes_cluster.rb +++ b/qa/qa/factory/resource/kubernetes_cluster.rb @@ -44,10 +44,11 @@ module QA page.await_installed(:helm) page.install!(:ingress) if @install_ingress - page.await_installed(:ingress) if @install_ingress page.install!(:prometheus) if @install_prometheus - page.await_installed(:prometheus) if @install_prometheus page.install!(:runner) if @install_runner + + page.await_installed(:ingress) if @install_ingress + page.await_installed(:prometheus) if @install_prometheus page.await_installed(:runner) if @install_runner end end diff --git a/qa/qa/page/project/new.rb b/qa/qa/page/project/new.rb index 9e812fa7c74..1fb569b0f29 100644 --- a/qa/qa/page/project/new.rb +++ b/qa/qa/page/project/new.rb @@ -10,7 +10,7 @@ module QA view 'app/views/projects/_new_project_fields.html.haml' do element :project_namespace_select - element :project_namespace_field, /select :namespace_id.*class: 'select2/ + element :project_namespace_field, 'namespaces_options' element :project_path, 'text_field :path' element :project_description, 'text_area :description' element :project_create_button, "submit 'Create project'" diff --git a/qa/qa/page/project/operations/kubernetes/show.rb b/qa/qa/page/project/operations/kubernetes/show.rb index 4923304133e..e831edeb89e 100644 --- a/qa/qa/page/project/operations/kubernetes/show.rb +++ b/qa/qa/page/project/operations/kubernetes/show.rb @@ -16,6 +16,7 @@ module QA def install!(application_name) within(".js-cluster-application-row-#{application_name}") do + page.has_button?('Install', wait: 30) click_on 'Install' end end diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb index 0f739f61db9..752d3d93407 100644 --- a/qa/qa/page/project/settings/ci_cd.rb +++ b/qa/qa/page/project/settings/ci_cd.rb @@ -12,9 +12,9 @@ module QA # rubocop:disable Naming/FileName end view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do - element :enable_auto_devops_field, 'radio_button :enabled' + element :enable_auto_devops_field, 'check_box :enabled' element :domain_field, 'text_field :domain' - element :enable_auto_devops_button, "%strong= s_('CICD|Enable Auto DevOps')" + element :enable_auto_devops_button, "%strong= s_('CICD|Default to Auto DevOps pipeline')" element :domain_input, "%strong= _('Domain')" element :save_changes_button, "submit _('Save changes')" end @@ -33,7 +33,7 @@ module QA # rubocop:disable Naming/FileName def enable_auto_devops_with_domain(domain) expand_section(:autodevops_settings) do - choose 'Enable Auto DevOps' + check 'Default to Auto DevOps pipeline' fill_in 'Domain', with: domain click_on 'Save changes' end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index bad7a28556c..421ab006792 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -56,6 +56,57 @@ describe ApplicationController do end end + describe '#add_gon_variables' do + before do + Gon.clear + sign_in user + end + + let(:json_response) { JSON.parse(response.body) } + + controller(described_class) do + def index + render json: Gon.all_variables + end + end + + shared_examples 'setting gon variables' do + it 'sets gon variables' do + get :index, format: format + + expect(json_response.size).not_to be_zero + end + end + + shared_examples 'not setting gon variables' do + it 'does not set gon variables' do + get :index, format: format + + expect(json_response.size).to be_zero + end + end + + context 'with html format' do + let(:format) { :html } + + it_behaves_like 'setting gon variables' + + context 'for peek requests' do + before do + request.path = '/-/peek' + end + + it_behaves_like 'not setting gon variables' + end + end + + context 'with json format' do + let(:format) { :json } + + it_behaves_like 'not setting gon variables' + end + end + describe "#authenticate_user_from_personal_access_token!" do before do stub_authentication_activity_metrics(debug: false) diff --git a/spec/controllers/import/bitbucket_server_controller_spec.rb b/spec/controllers/import/bitbucket_server_controller_spec.rb new file mode 100644 index 00000000000..5024ef71771 --- /dev/null +++ b/spec/controllers/import/bitbucket_server_controller_spec.rb @@ -0,0 +1,154 @@ +require 'spec_helper' + +describe Import::BitbucketServerController do + let(:user) { create(:user) } + let(:project_key) { 'test-project' } + let(:repo_slug) { 'some-repo' } + let(:client) { instance_double(BitbucketServer::Client) } + + def assign_session_tokens + session[:bitbucket_server_url] = 'http://localhost:7990' + session[:bitbucket_server_username] = 'bitbucket' + session[:bitbucket_server_personal_access_token] = 'some-token' + end + + before do + sign_in(user) + allow(controller).to receive(:bitbucket_server_import_enabled?).and_return(true) + end + + describe 'GET new' do + render_views + + it 'shows the input form' do + get :new + + expect(response.body).to have_text('Bitbucket Server URL') + end + end + + describe 'POST create' do + before do + allow(controller).to receive(:bitbucket_client).and_return(client) + repo = double(name: 'my-project') + allow(client).to receive(:repo).with(project_key, repo_slug).and_return(repo) + assign_session_tokens + end + + set(:project) { create(:project) } + + it 'returns the new project' do + allow(Gitlab::BitbucketServerImport::ProjectCreator) + .to receive(:new).with(project_key, repo_slug, anything, 'my-project', user.namespace, user, anything) + .and_return(double(execute: project)) + + post :create, project: project_key, repository: repo_slug, format: :json + + expect(response).to have_gitlab_http_status(200) + end + + it 'returns an error when an invalid project key is used' do + post :create, project: 'some&project' + + expect(response).to have_gitlab_http_status(422) + end + + it 'returns an error when an invalid repository slug is used' do + post :create, project: 'some-project', repository: 'try*this' + + expect(response).to have_gitlab_http_status(422) + end + + it 'returns an error when the project cannot be found' do + allow(client).to receive(:repo).with(project_key, repo_slug).and_return(nil) + + post :create, project: project_key, repository: repo_slug, format: :json + + expect(response).to have_gitlab_http_status(422) + end + + it 'returns an error when the project cannot be saved' do + allow(Gitlab::BitbucketServerImport::ProjectCreator) + .to receive(:new).with(project_key, repo_slug, anything, 'my-project', user.namespace, user, anything) + .and_return(double(execute: build(:project))) + + post :create, project: project_key, repository: repo_slug, format: :json + + expect(response).to have_gitlab_http_status(422) + end + + it "returns an error when the server can't be contacted" do + expect(client).to receive(:repo).with(project_key, repo_slug).and_raise(BitbucketServer::Client::ServerError) + + post :create, project: project_key, repository: repo_slug, format: :json + + expect(response).to have_gitlab_http_status(422) + end + end + + describe 'POST configure' do + let(:token) { 'token' } + let(:username) { 'bitbucket-user' } + let(:url) { 'http://localhost:7990/bitbucket' } + + it 'clears out existing session' do + post :configure + + expect(session[:bitbucket_server_url]).to be_nil + expect(session[:bitbucket_server_username]).to be_nil + expect(session[:bitbucket_server_personal_access_token]).to be_nil + + expect(response).to have_gitlab_http_status(302) + expect(response).to redirect_to(status_import_bitbucket_server_path) + end + + it 'sets the session variables' do + post :configure, personal_access_token: token, bitbucket_username: username, bitbucket_server_url: url + + expect(session[:bitbucket_server_url]).to eq(url) + expect(session[:bitbucket_server_username]).to eq(username) + expect(session[:bitbucket_server_personal_access_token]).to eq(token) + expect(response).to have_gitlab_http_status(302) + expect(response).to redirect_to(status_import_bitbucket_server_path) + end + end + + describe 'GET status' do + render_views + + before do + allow(controller).to receive(:bitbucket_client).and_return(client) + + @repo = double(slug: 'vim', project_key: 'asd', full_name: 'asd/vim', "valid?" => true, project_name: 'asd', browse_url: 'http://test', name: 'vim') + @invalid_repo = double(slug: 'invalid', project_key: 'foobar', full_name: 'asd/foobar', "valid?" => false, browse_url: 'http://bad-repo') + assign_session_tokens + end + + it 'assigns repository categories' do + created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id, import_source: 'foo/bar', import_status: 'finished') + expect(client).to receive(:repos).and_return([@repo, @invalid_repo]) + + get :status + + expect(assigns(:already_added_projects)).to eq([created_project]) + expect(assigns(:repos)).to eq([@repo]) + expect(assigns(:incompatible_repos)).to eq([@invalid_repo]) + end + end + + describe 'GET jobs' do + before do + assign_session_tokens + end + + it 'returns a list of imported projects' do + created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id) + + get :jobs + + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(created_project.id) + expect(json_response.first['import_status']).to eq('none') + end + end +end diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb index d2f133f972a..73bf169085f 100644 --- a/spec/controllers/projects/deploy_keys_controller_spec.rb +++ b/spec/controllers/projects/deploy_keys_controller_spec.rb @@ -19,7 +19,7 @@ describe Projects::DeployKeysController do it 'redirects to blob' do get :index, params - expect(response).to redirect_to(namespace_project_settings_repository_path(params)) + expect(response).to redirect_to(project_settings_repository_path(project, anchor: 'js-deploy-keys-settings')) end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 375018e2229..d9bb3981539 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -597,6 +597,12 @@ describe Projects::MergeRequestsController do context 'when comparison is being processed' do let(:comparison_status) { { status: :parsing } } + it 'sends polling interval' do + expect(Gitlab::PollingInterval).to receive(:set_header) + + subject + end + it 'returns 204 HTTP status' do subject @@ -607,6 +613,12 @@ describe Projects::MergeRequestsController do context 'when comparison is done' do let(:comparison_status) { { status: :parsed, data: { summary: 1 } } } + it 'does not send polling interval' do + expect(Gitlab::PollingInterval).not_to receive(:set_header) + + subject + end + it 'returns 200 HTTP status' do subject @@ -618,6 +630,12 @@ describe Projects::MergeRequestsController do context 'when user created corrupted test reports' do let(:comparison_status) { { status: :error, status_reason: 'Failed to parse test reports' } } + it 'does not send polling interval' do + expect(Gitlab::PollingInterval).not_to receive(:set_header) + + subject + end + it 'returns 400 HTTP status' do subject @@ -629,6 +647,12 @@ describe Projects::MergeRequestsController do context 'when something went wrong on our system' do let(:comparison_status) { {} } + it 'does not send polling interval' do + expect(Gitlab::PollingInterval).not_to receive(:set_header) + + subject + end + it 'returns 500 HTTP status' do subject diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 6c2d1c7e92b..3190f1ce9d4 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -42,16 +42,45 @@ describe Projects::MilestonesController do describe "#index" do context "as html" do - before do - get :index, namespace_id: project.namespace.id, project_id: project.id + def render_index(project:, page:) + get :index, namespace_id: project.namespace.id, + project_id: project.id, + page: page end it "queries only projects milestones" do + render_index project: project, page: 1 + milestones = assigns(:milestones) expect(milestones.count).to eq(1) expect(milestones.where(project_id: nil)).to be_empty end + + it 'renders paginated milestones without missing or duplicates' do + allow(Milestone).to receive(:default_per_page).and_return(2) + create_list(:milestone, 5, project: project) + + render_index project: project, page: 1 + page_1_milestones = assigns(:milestones) + expect(page_1_milestones.size).to eq(2) + + render_index project: project, page: 2 + page_2_milestones = assigns(:milestones) + expect(page_2_milestones.size).to eq(2) + + render_index project: project, page: 3 + page_3_milestones = assigns(:milestones) + expect(page_3_milestones.size).to eq(2) + + rendered_milestone_ids = + page_1_milestones.pluck(:id) + + page_2_milestones.pluck(:id) + + page_3_milestones.pluck(:id) + + expect(rendered_milestone_ids) + .to match_array(project.milestones.pluck(:id)) + end end context "as json" do diff --git a/spec/controllers/projects/mirrors_controller_spec.rb b/spec/controllers/projects/mirrors_controller_spec.rb index 5d64f362252..6114eef7003 100644 --- a/spec/controllers/projects/mirrors_controller_spec.rb +++ b/spec/controllers/projects/mirrors_controller_spec.rb @@ -36,7 +36,7 @@ describe Projects::MirrorsController do it 'processes a successful update' do do_put(project, remote_mirrors_attributes: remote_mirror_attributes) - expect(response).to redirect_to(project_settings_repository_path(project)) + expect(response).to redirect_to(project_settings_repository_path(project, anchor: 'js-push-remote-settings')) expect(flash[:notice]).to match(/successfully updated/) end @@ -53,7 +53,7 @@ describe Projects::MirrorsController do it 'processes an unsuccessful update' do do_put(project, remote_mirrors_attributes: remote_mirror_attributes) - expect(response).to redirect_to(project_settings_repository_path(project)) + expect(response).to redirect_to(project_settings_repository_path(project, anchor: 'js-push-remote-settings')) expect(flash[:alert]).to match(/Only allowed protocols are/) end diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb index 1ce7e84bef9..58f2817c7cc 100644 --- a/spec/controllers/projects/todos_controller_spec.rb +++ b/spec/controllers/projects/todos_controller_spec.rb @@ -5,10 +5,29 @@ describe Projects::TodosController do let(:project) { create(:project) } let(:issue) { create(:issue, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } + let(:parent) { project } + + shared_examples 'project todos actions' do + it_behaves_like 'todos actions' + + context 'when not authorized for resource' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) + project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) + sign_in(user) + end + + it "doesn't create todo" do + expect { post_create }.not_to change { user.todos.count } + expect(response).to have_gitlab_http_status(404) + end + end + end context 'Issues' do describe 'POST create' do - def go + def post_create post :create, namespace_id: project.namespace, project_id: project, @@ -17,66 +36,13 @@ describe Projects::TodosController do format: 'html' end - context 'when authorized' do - before do - sign_in(user) - project.add_developer(user) - end - - it 'creates todo for issue' do - expect do - go - end.to change { user.todos.count }.by(1) - - expect(response).to have_gitlab_http_status(200) - end - - it 'returns todo path and pending count' do - go - - expect(response).to have_gitlab_http_status(200) - expect(json_response['count']).to eq 1 - expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}}) - end - end - - context 'when not authorized for project' do - it 'does not create todo for issue that user has no access to' do - sign_in(user) - expect do - go - end.to change { user.todos.count }.by(0) - - expect(response).to have_gitlab_http_status(404) - end - - it 'does not create todo for issue when user not logged in' do - expect do - go - end.to change { user.todos.count }.by(0) - - expect(response).to have_gitlab_http_status(302) - end - end - - context 'when not authorized for issue' do - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) - project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) - sign_in(user) - end - - it "doesn't create todo" do - expect { go }.not_to change { user.todos.count } - expect(response).to have_gitlab_http_status(404) - end - end + it_behaves_like 'project todos actions' end end context 'Merge Requests' do describe 'POST create' do - def go + def post_create post :create, namespace_id: project.namespace, project_id: project, @@ -85,60 +51,7 @@ describe Projects::TodosController do format: 'html' end - context 'when authorized' do - before do - sign_in(user) - project.add_developer(user) - end - - it 'creates todo for merge request' do - expect do - go - end.to change { user.todos.count }.by(1) - - expect(response).to have_gitlab_http_status(200) - end - - it 'returns todo path and pending count' do - go - - expect(response).to have_gitlab_http_status(200) - expect(json_response['count']).to eq 1 - expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}}) - end - end - - context 'when not authorized for project' do - it 'does not create todo for merge request user has no access to' do - sign_in(user) - expect do - go - end.to change { user.todos.count }.by(0) - - expect(response).to have_gitlab_http_status(404) - end - - it 'does not create todo for merge request user has no access to' do - expect do - go - end.to change { user.todos.count }.by(0) - - expect(response).to have_gitlab_http_status(302) - end - end - - context 'when not authorized for merge_request' do - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) - project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) - sign_in(user) - end - - it "doesn't create todo" do - expect { go }.not_to change { user.todos.count } - expect(response).to have_gitlab_http_status(404) - end - end + it_behaves_like 'project todos actions' end end end diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index 3e4277e4ba6..7c4a440b9a9 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -32,11 +32,21 @@ FactoryBot.define do updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago end - factory :clusters_applications_ingress, class: Clusters::Applications::Ingress - factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus - factory :clusters_applications_runner, class: Clusters::Applications::Runner + factory :clusters_applications_ingress, class: Clusters::Applications::Ingress do + cluster factory: %i(cluster with_installed_helm provided_by_gcp) + end + + factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus do + cluster factory: %i(cluster with_installed_helm provided_by_gcp) + end + + factory :clusters_applications_runner, class: Clusters::Applications::Runner do + cluster factory: %i(cluster with_installed_helm provided_by_gcp) + end + factory :clusters_applications_jupyter, class: Clusters::Applications::Jupyter do oauth_application factory: :oauth_application + cluster factory: %i(cluster with_installed_helm provided_by_gcp) end end end diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb index 0430762c1ff..bbeba8ce8b9 100644 --- a/spec/factories/clusters/clusters.rb +++ b/spec/factories/clusters/clusters.rb @@ -36,5 +36,9 @@ FactoryBot.define do trait :production_environment do sequence(:environment_scope) { |n| "production#{n}/*" } end + + trait :with_installed_helm do + application_helm factory: %i(clusters_applications_helm installed) + end end end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index cbc0b943396..b8b089b069b 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -112,6 +112,10 @@ FactoryBot.define do end end + after(:create) do |merge_request, evaluator| + merge_request.cache_merge_request_closes_issues! + end + factory :merged_merge_request, traits: [:merged] factory :closed_merge_request, traits: [:closed] factory :reopened_merge_request, traits: [:opened] diff --git a/spec/factories/milestones.rb b/spec/factories/milestones.rb index f95632e7187..019e4420212 100644 --- a/spec/factories/milestones.rb +++ b/spec/factories/milestones.rb @@ -18,6 +18,11 @@ FactoryBot.define do state "closed" end + trait :with_dates do + start_date { Date.new(2000, 1, 1) } + due_date { Date.new(2000, 1, 30) } + end + after(:build, :stub) do |milestone, evaluator| if evaluator.group milestone.group = evaluator.group diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb index 94f8caedfa6..14486c80341 100644 --- a/spec/factories/todos.rb +++ b/spec/factories/todos.rb @@ -1,8 +1,8 @@ FactoryBot.define do factory :todo do project - author { project.creator } - user { project.creator } + author { project&.creator || user } + user { project&.creator || user } target factory: :issue action { Todo::ASSIGNED } diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb index de406d7d966..238ea2a25bd 100644 --- a/spec/features/admin/admin_labels_spec.rb +++ b/spec/features/admin/admin_labels_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'admin issues labels' do it 'deletes all labels', :js do page.within '.labels' do - page.all('.btn-remove').each do |remove| + page.all('.remove-row').each do |remove| accept_confirm { remove.click } wait_for_requests end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index a852ca689e7..af1c153dec8 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -175,7 +175,7 @@ describe 'Admin updates settings' do it 'Change CI/CD settings' do page.within('.as-ci-cd') do - check 'Enabled Auto DevOps for projects by default' + check 'Default to Auto DevOps pipeline for all projects' fill_in 'Auto devops domain', with: 'domain.com' click_button 'Save changes' end diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb index ad02b454aee..8ed4051856e 100644 --- a/spec/features/explore/groups_list_spec.rb +++ b/spec/features/explore/groups_list_spec.rb @@ -53,14 +53,14 @@ describe 'Explore Groups page', :js do expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1") # Archive project - empty_project.archive! + ::Projects::UpdateService.new(empty_project, user, archived: true).execute visit explore_groups_path # Check project count expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0") # Unarchive project - empty_project.unarchive! + ::Projects::UpdateService.new(empty_project, user, archived: false).execute visit explore_groups_path # Check project count diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 97d8776b15a..176f4a668ff 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -52,6 +52,7 @@ describe 'Group issues page' do context 'issues list', :nested_groups do let(:subgroup) { create(:group, parent: group) } let(:subgroup_project) { create(:project, :public, group: subgroup)} + let(:user_in_group) { create(:group_member, :maintainer, user: create(:user), group: group ).user } let!(:issue) { create(:issue, project: project, title: 'root group issue') } let!(:subgroup_issue) { create(:issue, project: subgroup_project, title: 'subgroup issue') } @@ -67,7 +68,7 @@ describe 'Group issues page' do context 'when project is archived' do before do - project.archive! + ::Projects::UpdateService.new(project, user_in_group, archived: true).execute end it 'does not render issue' do diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index bf4d5396df9..2d268ecab58 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -342,8 +342,9 @@ describe 'Merge request > User resolves diff notes and discussions', :js do end end - it 'shows jump to next discussion button' do - expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn')) + it 'shows jump to next discussion button, apart from the last one' do + expect(page).to have_selector('.discussion-reply-holder', count: 2) + expect(page).to have_selector('.discussion-reply-holder .discussion-next-btn', count: 1) end it 'displays next discussion even if hidden' do diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index b6b3844f2ae..b285cd7a7ac 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe 'Merge request > User sees merge widget', :js do include ProjectForksHelper + include TestReportsHelper let(:project) { create(:project, :repository) } let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) } @@ -325,4 +326,229 @@ describe 'Merge request > User sees merge widget', :js do expect(page).to have_content('This merge request is in the process of being merged') end end + + context 'when merge request has test reports' do + let!(:head_pipeline) do + create(:ci_pipeline, + :success, + project: project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + + let!(:build) { create(:ci_build, :success, pipeline: head_pipeline, project: project) } + + before do + merge_request.update!(head_pipeline_id: head_pipeline.id) + end + + context 'when result has not been parsed yet' do + let!(:job_artifact) { create(:ci_job_artifact, :junit, job: build, project: project) } + + before do + visit project_merge_request_path(project, merge_request) + end + + it 'shows parsing status' do + expect(page).to have_content('Test summary results are being parsed') + end + end + + context 'when result has already been parsed' do + context 'when JUnit xml is correctly formatted' do + let!(:job_artifact) { create(:ci_job_artifact, :junit, job: build, project: project) } + + before do + allow_any_instance_of(MergeRequest).to receive(:compare_test_reports).and_return(compared_data) + + visit project_merge_request_path(project, merge_request) + end + + it 'shows parsed results' do + expect(page).to have_content('Test summary contained') + end + end + + context 'when JUnit xml is corrupted' do + let!(:job_artifact) { create(:ci_job_artifact, :junit_with_corrupted_data, job: build, project: project) } + + before do + allow_any_instance_of(MergeRequest).to receive(:compare_test_reports).and_return(compared_data) + + visit project_merge_request_path(project, merge_request) + end + + it 'shows the error state' do + expect(page).to have_content('Test summary failed loading results') + end + end + + def compared_data + Ci::CompareTestReportsService.new(project).execute(nil, head_pipeline) + end + end + + context 'when test reports have been parsed correctly' do + let(:serialized_data) do + { + status: :parsed, + data: TestReportsComparerSerializer + .new(project: project) + .represent(comparer) + } + end + + before do + allow_any_instance_of(MergeRequest) + .to receive(:has_test_reports?).and_return(true) + allow_any_instance_of(MergeRequest) + .to receive(:compare_test_reports).and_return(serialized_data) + + visit project_merge_request_path(project, merge_request) + end + + context 'when a new failures exists' do + let(:base_reports) do + Gitlab::Ci::Reports::TestReports.new.tap do |reports| + reports.get_suite('rspec').add_test_case(create_test_case_rspec_success) + reports.get_suite('junit').add_test_case(create_test_case_java_success) + end + end + + let(:head_reports) do + Gitlab::Ci::Reports::TestReports.new.tap do |reports| + reports.get_suite('rspec').add_test_case(create_test_case_rspec_success) + reports.get_suite('junit').add_test_case(create_test_case_java_failed) + end + end + + it 'shows test reports summary which includes the new failure' do + within(".mr-section-container") do + click_button 'Expand' + + expect(page).to have_content('Test summary contained 1 failed test result out of 2 total tests') + within(".js-report-section-container") do + expect(page).to have_content('rspec found no changed test results out of 1 total test') + expect(page).to have_content('junit found 1 failed test result out of 1 total test') + expect(page).to have_content('New') + expect(page).to have_content('subtractTest') + end + end + end + + context 'when user clicks the new failure' do + it 'shows the test report detail' do + within(".mr-section-container") do + click_button 'Expand' + + within(".js-report-section-container") do + click_button 'subtractTest' + + expect(page).to have_content('6.66') + expect(page).to have_content(sample_java_failed_message) + end + end + end + end + end + + context 'when an existing failure exists' do + let(:base_reports) do + Gitlab::Ci::Reports::TestReports.new.tap do |reports| + reports.get_suite('rspec').add_test_case(create_test_case_rspec_failed) + reports.get_suite('junit').add_test_case(create_test_case_java_success) + end + end + + let(:head_reports) do + Gitlab::Ci::Reports::TestReports.new.tap do |reports| + reports.get_suite('rspec').add_test_case(create_test_case_rspec_failed) + reports.get_suite('junit').add_test_case(create_test_case_java_success) + end + end + + it 'shows test reports summary which includes the existing failure' do + within(".mr-section-container") do + click_button 'Expand' + + expect(page).to have_content('Test summary contained 1 failed test result out of 2 total tests') + within(".js-report-section-container") do + expect(page).to have_content('rspec found 1 failed test result out of 1 total test') + expect(page).to have_content('junit found no changed test results out of 1 total test') + expect(page).not_to have_content('New') + expect(page).to have_content('Test#sum when a is 2 and b is 2 returns summary') + end + end + end + + context 'when user clicks the existing failure' do + it 'shows test report detail of it' do + within(".mr-section-container") do + click_button 'Expand' + + within(".js-report-section-container") do + click_button 'Test#sum when a is 2 and b is 2 returns summary' + + expect(page).to have_content('2.22') + expect(page).to have_content(sample_rspec_failed_message) + end + end + end + end + end + + context 'when a resolved failure exists' do + let(:base_reports) do + Gitlab::Ci::Reports::TestReports.new.tap do |reports| + reports.get_suite('rspec').add_test_case(create_test_case_rspec_success) + reports.get_suite('junit').add_test_case(create_test_case_java_failed) + end + end + + let(:head_reports) do + Gitlab::Ci::Reports::TestReports.new.tap do |reports| + reports.get_suite('rspec').add_test_case(create_test_case_rspec_success) + reports.get_suite('junit').add_test_case(create_test_case_java_resolved) + end + end + + let(:create_test_case_java_resolved) do + create_test_case_java_failed.tap do |test_case| + test_case.instance_variable_set("@status", Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS) + end + end + + it 'shows test reports summary which includes the resolved failure' do + within(".mr-section-container") do + click_button 'Expand' + + expect(page).to have_content('Test summary contained 1 fixed test result out of 2 total tests') + within(".js-report-section-container") do + expect(page).to have_content('rspec found no changed test results out of 1 total test') + expect(page).to have_content('junit found 1 fixed test result out of 1 total test') + expect(page).to have_content('subtractTest') + end + end + end + + context 'when user clicks the resolved failure' do + it 'shows test report detail of it' do + within(".mr-section-container") do + click_button 'Expand' + + within(".js-report-section-container") do + click_button 'subtractTest' + + expect(page).to have_content('6.66') + end + end + end + end + end + + def comparer + Gitlab::Ci::Reports::TestReportsComparer.new(base_reports, head_reports) + end + end + end end diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 96bbe6f93f1..9e60b4995bd 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -8,6 +8,10 @@ describe 'User edit profile' do visit(profile_path) end + def submit_settings + click_button 'Update profile settings' + end + it 'changes user profile' do fill_in 'user_skype', with: 'testskype' fill_in 'user_linkedin', with: 'testlinkedin' @@ -16,7 +20,7 @@ describe 'User edit profile' do fill_in 'user_location', with: 'Ukraine' fill_in 'user_bio', with: 'I <3 GitLab' fill_in 'user_organization', with: 'GitLab' - click_button 'Update profile settings' + submit_settings expect(user.reload).to have_attributes( skype: 'testskype', @@ -34,7 +38,7 @@ describe 'User edit profile' do context 'user avatar' do before do attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif')) - click_button 'Update profile settings' + submit_settings end it 'changes user avatar' do @@ -56,30 +60,75 @@ describe 'User edit profile' do end end - context 'user status' do - it 'hides user status when the feature is disabled' do - stub_feature_flags(user_status_form: false) + context 'user status', :js do + def select_emoji(emoji_name) + toggle_button = find('.js-toggle-emoji-menu') + toggle_button.click + emoji_button = find(%Q{.js-status-emoji-menu .js-emoji-btn gl-emoji[data-name="#{emoji_name}"]}) + emoji_button.click + end + it 'shows the user status form' do visit(profile_path) - expect(page).not_to have_content('Current Status') + expect(page).to have_content('Current status') end - it 'shows the status form when the feature is enabled' do - stub_feature_flags(user_status_form: true) + it 'adds emoji to user status' do + emoji = 'biohazard' + visit(profile_path) + select_emoji(emoji) + submit_settings + visit user_path(user) + within('.cover-status') do + expect(page).to have_emoji(emoji) + end + end + + it 'adds message to user status' do + message = 'I have something to say' visit(profile_path) + fill_in 'js-status-message-field', with: message + submit_settings + + visit user_path(user) + within('.cover-status') do + expect(page).to have_emoji('speech_balloon') + expect(page).to have_content message + end + end - expect(page).to have_content('Current Status') + it 'adds message and emoji to user status' do + emoji = 'tanabata_tree' + message = 'Playing outside' + visit(profile_path) + select_emoji(emoji) + fill_in 'js-status-message-field', with: message + submit_settings + + visit user_path(user) + within('.cover-status') do + expect(page).to have_emoji(emoji) + expect(page).to have_content message + end end - it 'shows the status form when the feature is enabled by setting a cookie', :js do - stub_feature_flags(user_status_form: false) - set_cookie('feature_user_status_form', 'true') + it 'clears the user status' do + user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread') + + visit user_path(user) + within('.cover-status') do + expect(page).to have_emoji(user_status.emoji) + expect(page).to have_content user_status.message + end visit(profile_path) + click_button 'js-clear-user-status-button' + submit_settings - expect(page).to have_content('Current Status') + visit user_path(user) + expect(page).not_to have_selector '.cover-status' end end end diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 27589428896..1064f72c271 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -552,4 +552,33 @@ describe 'File blob', :js do end end end + + context 'for subgroups' do + let(:group) { create(:group) } + let(:subgroup) { create(:group, parent: group) } + let(:project) { create(:project, :public, :repository, group: subgroup) } + + it 'renders tree table without errors' do + visit_blob('README.md') + + expect(page).to have_selector('.file-content') + expect(page).not_to have_selector('.flash-alert') + end + + it 'displays a GPG badge' do + visit_blob('CONTRIBUTING.md', ref: '33f3729a45c02fc67d00adb1b8bca394b0e761d9') + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' + end + end + + context 'on signed merge commit' do + it 'displays a GPG badge' do + visit_blob('conflicting-file.md', ref: '6101e87e575de14b38b4e1ce180519a813671e10') + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' + end + end end diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb index a65ca662350..71d715237f5 100644 --- a/spec/features/projects/clusters/applications_spec.rb +++ b/spec/features/projects/clusters/applications_spec.rb @@ -46,12 +46,14 @@ describe 'Clusters Applications', :js do end end - it 'he sees status transition' do + it 'they see status transition' do page.within('.js-cluster-application-row-helm') do # FE sends request and gets the response, then the buttons is "Install" expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') + wait_until_helm_created! + Clusters::Cluster.last.application_helm.make_installing! # FE starts polling and update the buttons to "Installing" @@ -83,7 +85,7 @@ describe 'Clusters Applications', :js do end end - it 'he sees status transition' do + it 'they see status transition' do page.within('.js-cluster-application-row-ingress') do # FE sends request and gets the response, then the buttons is "Install" expect(page).to have_css('.js-cluster-application-install-button[disabled]') @@ -116,4 +118,14 @@ describe 'Clusters Applications', :js do end end end + + def wait_until_helm_created! + retries = 0 + + while Clusters::Cluster.last.application_helm.nil? + raise "Timed out waiting for helm application to be created in DB" if (retries += 1) > 3 + + sleep(1) + end + end end diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb index 742ecf82c38..30b0a5578ea 100644 --- a/spec/features/projects/settings/pipelines_settings_spec.rb +++ b/spec/features/projects/settings/pipelines_settings_spec.rb @@ -8,7 +8,6 @@ describe "Projects > Settings > Pipelines settings" do before do sign_in(user) project.add_role(user, role) - create(:project_auto_devops, project: project) end context 'for developer' do @@ -61,19 +60,58 @@ describe "Projects > Settings > Pipelines settings" do end describe 'Auto DevOps' do - it 'update auto devops settings' do - visit project_settings_ci_cd_path(project) + context 'when auto devops is turned on instance-wide' do + before do + stub_application_setting(auto_devops_enabled: true) + end + + it 'auto devops is on by default and can be manually turned off' do + visit project_settings_ci_cd_path(project) - page.within '#autodevops-settings' do - fill_in('project_auto_devops_attributes_domain', with: 'test.com') - page.choose('project_auto_devops_attributes_enabled_false') - click_on 'Save changes' + page.within '#autodevops-settings' do + expect(find_field('project_auto_devops_attributes_enabled')).to be_checked + expect(page).to have_content('instance enabled') + uncheck 'Default to Auto DevOps pipeline' + click_on 'Save changes' + end + + expect(page.status_code).to eq(200) + expect(project.auto_devops).to be_present + expect(project.auto_devops).not_to be_enabled + + page.within '#autodevops-settings' do + expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked + expect(page).not_to have_content('instance enabled') + end end + end - expect(page.status_code).to eq(200) - expect(project.auto_devops).to be_present - expect(project.auto_devops).not_to be_enabled - expect(project.auto_devops.domain).to eq('test.com') + context 'when auto devops is not turned on instance-wide' do + before do + stub_application_setting(auto_devops_enabled: false) + end + + it 'auto devops is off by default and can be manually turned on' do + visit project_settings_ci_cd_path(project) + + page.within '#autodevops-settings' do + expect(page).not_to have_content('instance enabled') + expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked + check 'Default to Auto DevOps pipeline' + fill_in('project_auto_devops_attributes_domain', with: 'test.com') + click_on 'Save changes' + end + + expect(page.status_code).to eq(200) + expect(project.auto_devops).to be_present + expect(project.auto_devops).to be_enabled + expect(project.auto_devops.domain).to eq('test.com') + + page.within '#autodevops-settings' do + expect(find_field('project_auto_devops_attributes_enabled')).to be_checked + expect(page).not_to have_content('instance enabled') + end + end end context 'when there is a cluster with ingress and external_ip' do diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb index d3aa4912099..9e58280b868 100644 --- a/spec/features/projects/tree/create_directory_spec.rb +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -22,7 +22,7 @@ describe 'Multi-file editor new directory', :js do end it 'creates directory in current directory' do - all('.ide-tree-header button').last.click + all('.ide-tree-actions button').last.click page.within('.modal') do find('.form-control').set('folder name') @@ -30,7 +30,7 @@ describe 'Multi-file editor new directory', :js do click_button('Create directory') end - first('.ide-tree-header button').click + first('.ide-tree-actions button').click page.within('.modal-dialog') do find('.form-control').set('file name') diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb index f836783cbff..a04d3566a7e 100644 --- a/spec/features/projects/tree/create_file_spec.rb +++ b/spec/features/projects/tree/create_file_spec.rb @@ -22,7 +22,7 @@ describe 'Multi-file editor new file', :js do end it 'creates file in current directory' do - first('.ide-tree-header button').click + first('.ide-tree-actions button').click page.within('.modal') do find('.form-control').set('file name') diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb index 9e15163fd72..8ae036cd29f 100644 --- a/spec/features/projects/tree/tree_show_spec.rb +++ b/spec/features/projects/tree/tree_show_spec.rb @@ -1,42 +1,86 @@ require 'spec_helper' -describe 'Projects tree' do +describe 'Projects tree', :js do let(:user) { create(:user) } let(:project) { create(:project, :repository) } before do project.add_maintainer(user) sign_in(user) + end + it 'renders tree table without errors' do visit project_tree_path(project, 'master') - end + wait_for_requests - it 'renders tree table' do expect(page).to have_selector('.tree-item') expect(page).not_to have_selector('.label-lfs', text: 'LFS') + expect(page).not_to have_selector('.flash-alert') end - context 'LFS' do - before do - visit project_tree_path(project, File.join('master', 'files/lfs')) + context 'for signed commit' do + it 'displays a GPG badge' do + visit project_tree_path(project, '33f3729a45c02fc67d00adb1b8bca394b0e761d9') + wait_for_requests + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' end + context 'on a directory that has not changed recently' do + it 'displays a GPG badge' do + tree_path = File.join('eee736adc74341c5d3e26cd0438bc697f26a7575', 'subdir') + visit project_tree_path(project, tree_path) + wait_for_requests + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' + end + end + end + + context 'LFS' do it 'renders LFS badge on blob item' do + visit project_tree_path(project, File.join('master', 'files/lfs')) + expect(page).to have_selector('.label-lfs', text: 'LFS') end end - context 'web IDE', :js do - before do + context 'web IDE' do + it 'opens folder in IDE' do visit project_tree_path(project, File.join('master', 'bar')) click_link 'Web IDE' + wait_for_requests find('.ide-file-list') + wait_for_requests + expect(page).to have_selector('.is-open', text: 'bar') end + end - it 'opens folder in IDE' do - expect(page).to have_selector('.is-open', text: 'bar') + context 'for subgroups' do + let(:group) { create(:group) } + let(:subgroup) { create(:group, parent: group) } + let(:project) { create(:project, :repository, group: subgroup) } + + it 'renders tree table without errors' do + visit project_tree_path(project, 'master') + wait_for_requests + + expect(page).to have_selector('.tree-item') + expect(page).not_to have_selector('.flash-alert') + end + + context 'for signed commit' do + it 'displays a GPG badge' do + visit project_tree_path(project, '33f3729a45c02fc67d00adb1b8bca394b0e761d9') + wait_for_requests + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' + end end end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 39b47d99040..56ed0c936a6 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -197,6 +197,49 @@ describe 'Project' do expect(page.status_code).to eq(200) end + + context 'for signed commit on default branch', :js do + before do + project.change_head('33f3729a45c02fc67d00adb1b8bca394b0e761d9') + end + + it 'displays a GPG badge' do + visit project_path(project) + wait_for_requests + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' + end + end + + context 'for subgroups', :js do + let(:group) { create(:group) } + let(:subgroup) { create(:group, parent: group) } + let(:project) { create(:project, :repository, group: subgroup) } + + it 'renders tree table without errors' do + wait_for_requests + + expect(page).to have_selector('.tree-item') + expect(page).not_to have_selector('.flash-alert') + end + + context 'for signed commit' do + before do + repository = project.repository + repository.write_ref("refs/heads/#{project.default_branch}", '33f3729a45c02fc67d00adb1b8bca394b0e761d9') + repository.expire_branches_cache + end + + it 'displays a GPG badge' do + visit project_path(project) + wait_for_requests + + expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge' + expect(page).to have_selector '.gpg-status-box.invalid' + end + end + end end describe 'activity view' do diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb index a9128104b87..af38f77c0c6 100644 --- a/spec/features/search/user_uses_header_search_field_spec.rb +++ b/spec/features/search/user_uses_header_search_field_spec.rb @@ -62,10 +62,6 @@ describe 'User uses header search field' do end end - it 'contains location badge' do - expect(page).to have_selector('.has-location-badge') - end - context 'when clicking the search field', :js do before do page.find('#search').click diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb index 5003eb508c2..ef0e55a1468 100644 --- a/spec/features/signed_commits_spec.rb +++ b/spec/features/signed_commits_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe 'GPG signed commits', :js do + set(:ref) { :'2d1096e3a0ecf1d2baf6dee036cc80775d4940ba' } let(:project) { create(:project, :repository) } it 'changes from unverified to verified when the user changes his email to match the gpg key' do @@ -13,7 +14,7 @@ describe 'GPG signed commits', :js do sign_in(user) - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within '#commits-list' do expect(page).to have_content 'Unverified' @@ -26,7 +27,7 @@ describe 'GPG signed commits', :js do user.update!(email: GpgHelpers::User1.emails.first) end - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within '#commits-list' do expect(page).to have_content 'Unverified' @@ -40,7 +41,7 @@ describe 'GPG signed commits', :js do sign_in(user) - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within '#commits-list' do expect(page).to have_content 'Unverified' @@ -52,7 +53,7 @@ describe 'GPG signed commits', :js do create :gpg_key, key: GpgHelpers::User1.public_key, user: user end - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within '#commits-list' do expect(page).to have_content 'Unverified' @@ -92,7 +93,7 @@ describe 'GPG signed commits', :js do end it 'unverified signature' do - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within(find('.commit', text: 'signed commit by bette cartwright')) do click_on 'Unverified' @@ -107,7 +108,7 @@ describe 'GPG signed commits', :js do it 'unverified signature: user email does not match the committer email, but is the same user' do user_2_key - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do click_on 'Unverified' @@ -124,7 +125,7 @@ describe 'GPG signed commits', :js do it 'unverified signature: user email does not match the committer email' do user_2_key - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within(find('.commit', text: 'signed commit by bette cartwright')) do click_on 'Unverified' @@ -141,7 +142,7 @@ describe 'GPG signed commits', :js do it 'verified and the gpg user has a gitlab profile' do user_1_key - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do click_on 'Verified' @@ -158,7 +159,7 @@ describe 'GPG signed commits', :js do it "verified and the gpg user's profile doesn't exist anymore" do user_1_key - visit project_commits_path(project, :'signed-commits') + visit project_commits_path(project, ref) # wait for the signature to get generated within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb index 4db73fccfb6..48f8b8bf77e 100644 --- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb +++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb @@ -15,7 +15,7 @@ describe 'User uploads avatar to profile' do visit user_path(user) - expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"])) + expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=90"])) # Cheating here to verify something that isn't user-facing, but is important expect(user.reload.avatar.file).to exist diff --git a/spec/finders/labels_finder_spec.rb b/spec/finders/labels_finder_spec.rb index eb2a4576e30..f5cec8e349a 100644 --- a/spec/finders/labels_finder_spec.rb +++ b/spec/finders/labels_finder_spec.rb @@ -55,7 +55,7 @@ describe LabelsFinder do context 'filtering by group_id' do it 'returns labels available for any non-archived project within the group' do group_1.add_developer(user) - project_1.archive! + ::Projects::UpdateService.new(project_1, user, archived: true).execute finder = described_class.new(user, group_id: group_1.id) expect(finder.execute).to eq [group_label_2, group_label_1, project_label_5] diff --git a/spec/finders/move_to_project_finder_spec.rb b/spec/finders/move_to_project_finder_spec.rb index 1511cb0e04c..1b3f44cced1 100644 --- a/spec/finders/move_to_project_finder_spec.rb +++ b/spec/finders/move_to_project_finder_spec.rb @@ -36,7 +36,7 @@ describe MoveToProjectFinder do it 'does not return archived projects' do reporter_project.add_reporter(user) - reporter_project.archive! + ::Projects::UpdateService.new(reporter_project, user, archived: true).execute other_reporter_project = create(:project) other_reporter_project.add_reporter(user) diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb index 9747b9402a7..7f7cfb2cb98 100644 --- a/spec/finders/todos_finder_spec.rb +++ b/spec/finders/todos_finder_spec.rb @@ -5,12 +5,50 @@ describe TodosFinder do let(:user) { create(:user) } let(:group) { create(:group) } let(:project) { create(:project, namespace: group) } + let(:issue) { create(:issue, project: project) } + let(:merge_request) { create(:merge_request, source_project: project) } let(:finder) { described_class } before do group.add_developer(user) end + describe '#execute' do + context 'filtering' do + let!(:todo1) { create(:todo, user: user, project: project, target: issue) } + let!(:todo2) { create(:todo, user: user, group: group, target: merge_request) } + + it 'returns correct todos when filtered by a project' do + todos = finder.new(user, { project_id: project.id }).execute + + expect(todos).to match_array([todo1]) + end + + it 'returns correct todos when filtered by a group' do + todos = finder.new(user, { group_id: group.id }).execute + + expect(todos).to match_array([todo1, todo2]) + end + + it 'returns correct todos when filtered by a type' do + todos = finder.new(user, { type: 'Issue' }).execute + + expect(todos).to match_array([todo1]) + end + + context 'with subgroups', :nested_groups do + let(:subgroup) { create(:group, parent: group) } + let!(:todo3) { create(:todo, user: user, group: subgroup, target: issue) } + + it 'returns todos from subgroups when filtered by a group' do + todos = finder.new(user, { group_id: group.id }).execute + + expect(todos).to match_array([todo1, todo2, todo3]) + end + end + end + end + describe '#sort' do context 'by date' do let!(:todo1) { create(:todo, user: user, project: project) } diff --git a/spec/fixtures/importers/bitbucket_server/activities.json b/spec/fixtures/importers/bitbucket_server/activities.json new file mode 100644 index 00000000000..09adfca9f31 --- /dev/null +++ b/spec/fixtures/importers/bitbucket_server/activities.json @@ -0,0 +1,1121 @@ +{ + "isLastPage": true, + "limit": 25, + "size": 8, + "start": 0, + "values": [ + { + "action": "COMMENTED", + "comment": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [ + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [ + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1530164016725, + "id": 11, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [ + { + "anchor": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "createdDate": 1530164016725, + "id": 11, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "text": "Ok", + "type": "COMMENT", + "updatedDate": 1530164016725, + "version": 0 + }, + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "createdDate": 1530164026000, + "id": 1, + "permittedOperations": { + "deletable": true, + "editable": true, + "transitionable": true + }, + "state": "OPEN", + "text": "here's a task" + } + ], + "text": "Ok", + "updatedDate": 1530164016725, + "version": 0 + }, + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1530165543990, + "id": 12, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "hi", + "updatedDate": 1530165543990, + "version": 0 + } + ], + "createdDate": 1530164013718, + "id": 10, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "Hello world", + "updatedDate": 1530164013718, + "version": 0 + }, + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1530165549932, + "id": 13, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "hello", + "updatedDate": 1530165549932, + "version": 0 + } + ], + "createdDate": 1530161499144, + "id": 9, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "is this a new line?", + "updatedDate": 1530161499144, + "version": 0 + }, + "commentAction": "ADDED", + "commentAnchor": { + "diffType": "EFFECTIVE", + "fileType": "TO", + "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "line": 1, + "lineType": "ADDED", + "orphaned": false, + "path": "CHANGELOG.md", + "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + }, + "createdDate": 1530161499144, + "diff": { + "destination": { + "components": [ + "CHANGELOG.md" + ], + "extension": "md", + "name": "CHANGELOG.md", + "parent": "", + "toString": "CHANGELOG.md" + }, + "hunks": [ + { + "destinationLine": 1, + "destinationSpan": 11, + "segments": [ + { + "lines": [ + { + "commentIds": [ + 9 + ], + "destination": 1, + "line": "# Edit 1", + "source": 1, + "truncated": false + }, + { + "destination": 2, + "line": "", + "source": 1, + "truncated": false + } + ], + "truncated": false, + "type": "ADDED" + }, + { + "lines": [ + { + "destination": 3, + "line": "# ChangeLog", + "source": 1, + "truncated": false + }, + { + "destination": 4, + "line": "", + "source": 2, + "truncated": false + }, + { + "destination": 5, + "line": "This log summarizes the changes in each released version of rouge. The versioning scheme", + "source": 3, + "truncated": false + }, + { + "destination": 6, + "line": "we use is semver, although we will often release new lexers in minor versions, as a", + "source": 4, + "truncated": false + }, + { + "destination": 7, + "line": "practical matter.", + "source": 5, + "truncated": false + }, + { + "destination": 8, + "line": "", + "source": 6, + "truncated": false + }, + { + "destination": 9, + "line": "## version TBD: (unreleased)", + "source": 7, + "truncated": false + }, + { + "destination": 10, + "line": "", + "source": 8, + "truncated": false + }, + { + "destination": 11, + "line": "* General", + "source": 9, + "truncated": false + } + ], + "truncated": false, + "type": "CONTEXT" + } + ], + "sourceLine": 1, + "sourceSpan": 9, + "truncated": false + } + ], + "properties": { + "current": true, + "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + }, + "source": null, + "truncated": false + }, + "id": 19, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "COMMENTED", + "comment": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1530053198463, + "id": 7, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "What about this line?", + "updatedDate": 1530053198463, + "version": 0 + }, + "commentAction": "ADDED", + "commentAnchor": { + "diffType": "EFFECTIVE", + "fileType": "FROM", + "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "line": 9, + "lineType": "CONTEXT", + "orphaned": false, + "path": "CHANGELOG.md", + "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + }, + "createdDate": 1530053198463, + "diff": { + "destination": { + "components": [ + "CHANGELOG.md" + ], + "extension": "md", + "name": "CHANGELOG.md", + "parent": "", + "toString": "CHANGELOG.md" + }, + "hunks": [ + { + "destinationLine": 1, + "destinationSpan": 12, + "segments": [ + { + "lines": [ + { + "destination": 1, + "line": "# Edit 1", + "source": 1, + "truncated": false + }, + { + "destination": 2, + "line": "", + "source": 1, + "truncated": false + } + ], + "truncated": false, + "type": "ADDED" + }, + { + "lines": [ + { + "destination": 3, + "line": "# ChangeLog", + "source": 1, + "truncated": false + }, + { + "destination": 4, + "line": "", + "source": 2, + "truncated": false + }, + { + "destination": 5, + "line": "This log summarizes the changes in each released version of rouge. The versioning scheme", + "source": 3, + "truncated": false + }, + { + "destination": 6, + "line": "we use is semver, although we will often release new lexers in minor versions, as a", + "source": 4, + "truncated": false + }, + { + "destination": 7, + "line": "practical matter.", + "source": 5, + "truncated": false + }, + { + "destination": 8, + "line": "", + "source": 6, + "truncated": false + }, + { + "destination": 9, + "line": "## version TBD: (unreleased)", + "source": 7, + "truncated": false + }, + { + "destination": 10, + "line": "", + "source": 8, + "truncated": false + }, + { + "commentIds": [ + 7 + ], + "destination": 11, + "line": "* General", + "source": 9, + "truncated": false + }, + { + "destination": 12, + "line": " * Load pastie theme ([#809](https://github.com/jneen/rouge/pull/809) by rramsden)", + "source": 10, + "truncated": false + } + ], + "truncated": false, + "type": "CONTEXT" + } + ], + "sourceLine": 1, + "sourceSpan": 10, + "truncated": false + } + ], + "properties": { + "current": true, + "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + }, + "source": null, + "truncated": false + }, + "id": 14, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "COMMENTED", + "comment": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [ + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [ + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1530143330513, + "id": 8, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "How about this?", + "updatedDate": 1530143330513, + "version": 0 + } + ], + "createdDate": 1530053193795, + "id": 6, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "It does.", + "updatedDate": 1530053193795, + "version": 0 + } + ], + "createdDate": 1530053187904, + "id": 5, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "Does this line make sense?", + "updatedDate": 1530053187904, + "version": 0 + }, + "commentAction": "ADDED", + "commentAnchor": { + "diffType": "EFFECTIVE", + "fileType": "FROM", + "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "line": 3, + "lineType": "CONTEXT", + "orphaned": false, + "path": "CHANGELOG.md", + "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + }, + "createdDate": 1530053187904, + "diff": { + "destination": { + "components": [ + "CHANGELOG.md" + ], + "extension": "md", + "name": "CHANGELOG.md", + "parent": "", + "toString": "CHANGELOG.md" + }, + "hunks": [ + { + "destinationLine": 1, + "destinationSpan": 12, + "segments": [ + { + "lines": [ + { + "destination": 1, + "line": "# Edit 1", + "source": 1, + "truncated": false + }, + { + "destination": 2, + "line": "", + "source": 1, + "truncated": false + } + ], + "truncated": false, + "type": "ADDED" + }, + { + "lines": [ + { + "destination": 3, + "line": "# ChangeLog", + "source": 1, + "truncated": false + }, + { + "destination": 4, + "line": "", + "source": 2, + "truncated": false + }, + { + "commentIds": [ + 5 + ], + "destination": 5, + "line": "This log summarizes the changes in each released version of rouge. The versioning scheme", + "source": 3, + "truncated": false + }, + { + "destination": 6, + "line": "we use is semver, although we will often release new lexers in minor versions, as a", + "source": 4, + "truncated": false + }, + { + "destination": 7, + "line": "practical matter.", + "source": 5, + "truncated": false + }, + { + "destination": 8, + "line": "", + "source": 6, + "truncated": false + }, + { + "destination": 9, + "line": "## version TBD: (unreleased)", + "source": 7, + "truncated": false + }, + { + "destination": 10, + "line": "", + "source": 8, + "truncated": false + }, + { + "destination": 11, + "line": "* General", + "source": 9, + "truncated": false + }, + { + "destination": 12, + "line": " * Load pastie theme ([#809](https://github.com/jneen/rouge/pull/809) by rramsden)", + "source": 10, + "truncated": false + } + ], + "truncated": false, + "type": "CONTEXT" + } + ], + "sourceLine": 1, + "sourceSpan": 10, + "truncated": false + } + ], + "properties": { + "current": true, + "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + }, + "source": null, + "truncated": false + }, + "id": 12, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "COMMENTED", + "comment": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1529813304164, + "id": 4, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "Hello world", + "updatedDate": 1529813304164, + "version": 0 + }, + "commentAction": "ADDED", + "createdDate": 1529813304164, + "id": 11, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "MERGED", + "commit": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "authorTimestamp": 1529727872000, + "committer": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "committerTimestamp": 1529727872000, + "displayId": "839fa9a2d43", + "id": "839fa9a2d434eb697815b8fcafaecc51accfdbbc", + "message": "Merge pull request #1 in TEST/rouge from root/CHANGELOGmd-1529725646923 to master\n\n* commit '66fbe6a097803f0acb7342b19563f710657ce5a2':\n CHANGELOG.md edited online with Bitbucket", + "parents": [ + { + "author": { + "emailAddress": "dblessing@users.noreply.github.com", + "name": "Drew Blessing" + }, + "authorTimestamp": 1529604583000, + "committer": { + "emailAddress": "noreply@github.com", + "name": "GitHub" + }, + "committerTimestamp": 1529604583000, + "displayId": "c5f4288162e", + "id": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + "message": "Merge pull request #949 from jneen/dblessing-patch-1\n\nAdd 'obj-c', 'obj_c' as ObjectiveC aliases", + "parents": [ + { + "displayId": "ea7675f741e", + "id": "ea7675f741ee28f3f177ff32a9bde192742ffc59" + }, + { + "displayId": "386b95a977b", + "id": "386b95a977b331e267497aa5206861774656f0c5" + } + ] + }, + { + "author": { + "emailAddress": "test.user@example.com", + "name": "root" + }, + "authorTimestamp": 1529725651000, + "committer": { + "emailAddress": "test.user@example.com", + "name": "root" + }, + "committerTimestamp": 1529725651000, + "displayId": "66fbe6a0978", + "id": "66fbe6a097803f0acb7342b19563f710657ce5a2", + "message": "CHANGELOG.md edited online with Bitbucket", + "parents": [ + { + "displayId": "c5f4288162e", + "id": "c5f4288162e2e6218180779c7f6ac1735bb56eab" + } + ] + } + ] + }, + "createdDate": 1529727872302, + "id": 7, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "COMMENTED", + "comment": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [ + { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1529813297478, + "id": 3, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "This is a thread", + "updatedDate": 1529813297478, + "version": 0 + } + ], + "createdDate": 1529725692591, + "id": 2, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "What about this?", + "updatedDate": 1529725692591, + "version": 0 + }, + "commentAction": "ADDED", + "createdDate": 1529725692591, + "id": 6, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "COMMENTED", + "comment": { + "author": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + }, + "comments": [], + "createdDate": 1529725685910, + "id": 1, + "permittedOperations": { + "deletable": true, + "editable": true + }, + "properties": { + "repositoryId": 1 + }, + "tasks": [], + "text": "This is a test.\n\n[analyze.json](attachment:1/1f32f09d97%2Fanalyze.json)\n", + "updatedDate": 1529725685910, + "version": 0 + }, + "commentAction": "ADDED", + "createdDate": 1529725685910, + "id": 5, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + }, + { + "action": "OPENED", + "createdDate": 1529725657542, + "id": 4, + "user": { + "active": true, + "displayName": "root", + "emailAddress": "test.user@example.com", + "id": 1, + "links": { + "self": [ + { + "href": "http://localhost:7990/users/root" + } + ] + }, + "name": "root", + "slug": "root", + "type": "NORMAL" + } + } + ] +} diff --git a/spec/fixtures/importers/bitbucket_server/pull_request.json b/spec/fixtures/importers/bitbucket_server/pull_request.json new file mode 100644 index 00000000000..6c7fcf3b04c --- /dev/null +++ b/spec/fixtures/importers/bitbucket_server/pull_request.json @@ -0,0 +1,146 @@ +{ + "author":{ + "approved":false, + "role":"AUTHOR", + "status":"UNAPPROVED", + "user":{ + "active":true, + "displayName":"root", + "emailAddress":"joe.montana@49ers.com", + "id":1, + "links":{ + "self":[ + { + "href":"http://localhost:7990/users/root" + } + ] + }, + "name":"root", + "slug":"root", + "type":"NORMAL" + } + }, + "closed":true, + "closedDate":1530600648850, + "createdDate":1530600635690, + "description":"Test", + "fromRef":{ + "displayId":"root/CODE_OF_CONDUCTmd-1530600625006", + "id":"refs/heads/root/CODE_OF_CONDUCTmd-1530600625006", + "latestCommit":"074e2b4dddc5b99df1bf9d4a3f66cfc15481fdc8", + "repository":{ + "forkable":true, + "id":1, + "links":{ + "clone":[ + { + "href":"http://root@localhost:7990/scm/test/rouge.git", + "name":"http" + }, + { + "href":"ssh://git@localhost:7999/test/rouge.git", + "name":"ssh" + } + ], + "self":[ + { + "href":"http://localhost:7990/projects/TEST/repos/rouge/browse" + } + ] + }, + "name":"rouge", + "project":{ + "description":"Test", + "id":1, + "key":"TEST", + "links":{ + "self":[ + { + "href":"http://localhost:7990/projects/TEST" + } + ] + }, + "name":"test", + "public":false, + "type":"NORMAL" + }, + "public":false, + "scmId":"git", + "slug":"rouge", + "state":"AVAILABLE", + "statusMessage":"Available" + } + }, + "id":7, + "links":{ + "self":[ + { + "href":"http://localhost:7990/projects/TEST/repos/rouge/pull-requests/7" + } + ] + }, + "locked":false, + "open":false, + "participants":[ + + ], + "properties":{ + "commentCount":1, + "openTaskCount":0, + "resolvedTaskCount":0 + }, + "reviewers":[ + + ], + "state":"MERGED", + "title":"Added a new line", + "toRef":{ + "displayId":"master", + "id":"refs/heads/master", + "latestCommit":"839fa9a2d434eb697815b8fcafaecc51accfdbbc", + "repository":{ + "forkable":true, + "id":1, + "links":{ + "clone":[ + { + "href":"http://root@localhost:7990/scm/test/rouge.git", + "name":"http" + }, + { + "href":"ssh://git@localhost:7999/test/rouge.git", + "name":"ssh" + } + ], + "self":[ + { + "href":"http://localhost:7990/projects/TEST/repos/rouge/browse" + } + ] + }, + "name":"rouge", + "project":{ + "description":"Test", + "id":1, + "key":"TEST", + "links":{ + "self":[ + { + "href":"http://localhost:7990/projects/TEST" + } + ] + }, + "name":"test", + "public":false, + "type":"NORMAL" + }, + "public":false, + "scmId":"git", + "slug":"rouge", + "state":"AVAILABLE", + "statusMessage":"Available" + } + }, + "updatedDate":1530600648850, + "version":2 +} diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 77410e0070c..f76ed4bfda4 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -21,6 +21,27 @@ describe IssuablesHelper do end end + describe '#group_dropdown_label' do + let(:group) { create(:group) } + let(:default) { 'default label' } + + it 'returns default group label when group_id is nil' do + expect(group_dropdown_label(nil, default)).to eq('default label') + end + + it 'returns "any group" when group_id is 0' do + expect(group_dropdown_label('0', default)).to eq('Any group') + end + + it 'returns group full path when a group was found for the provided id' do + expect(group_dropdown_label(group.id, default)).to eq(group.full_name) + end + + it 'returns default label when a group was not found for the provided id' do + expect(group_dropdown_label(9999, default)).to eq('default label') + end + end + describe '#issuable_labels_tooltip' do it 'returns label text with no labels' do expect(issuable_labels_tooltip([])).to eq("Labels") diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb index 343e140f5fb..234690e742b 100644 --- a/spec/helpers/namespaces_helper_spec.rb +++ b/spec/helpers/namespaces_helper_spec.rb @@ -31,6 +31,44 @@ describe NamespacesHelper do expect(options).to include(user.name) end + it 'avoids duplicate groups when extra_group is used' do + allow(helper).to receive(:current_user).and_return(admin) + + options = helper.namespaces_options(user_group.id, display_path: true, extra_group: build(:group, name: admin_group.name)) + + expect(options.scan("data-name=\"#{admin_group.name}\"").count).to eq(1) + expect(options).to include(admin_group.name) + end + + it 'selects existing group' do + allow(helper).to receive(:current_user).and_return(admin) + + options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group) + + expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"") + expect(options).to include(admin_group.name) + end + + it 'selects the new group by default' do + allow(helper).to receive(:current_user).and_return(user) + + options = helper.namespaces_options(:extra_group, display_path: true, extra_group: build(:group, name: 'new-group')) + + expect(options).to include(user_group.name) + expect(options).not_to include(admin_group.name) + expect(options).to include("selected=\"selected\" value=\"-1\"") + end + + it 'falls back to current user selection' do + allow(helper).to receive(:current_user).and_return(user) + + options = helper.namespaces_options(:extra_group, display_path: true, extra_group: build(:group, name: admin_group.name)) + + expect(options).to include(user_group.name) + expect(options).not_to include(admin_group.name) + expect(options).to include("selected=\"selected\" value=\"#{user.namespace.id}\"") + end + it 'returns only groups if groups_only option is true' do allow(helper).to receive(:current_user).and_return(user) diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js index 38ae5b7e00c..dcb1c781591 100644 --- a/spec/javascripts/autosave_spec.js +++ b/spec/javascripts/autosave_spec.js @@ -59,12 +59,10 @@ describe('Autosave', () => { Autosave.prototype.restore.call(autosave); - expect( - field.trigger, - ).toHaveBeenCalled(); + expect(field.trigger).toHaveBeenCalled(); }); - it('triggers native event', (done) => { + it('triggers native event', done => { autosave.field.get(0).addEventListener('change', () => { done(); }); @@ -81,9 +79,7 @@ describe('Autosave', () => { it('does not trigger event', () => { spyOn(field, 'trigger').and.callThrough(); - expect( - field.trigger, - ).not.toHaveBeenCalled(); + expect(field.trigger).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 7a32e84bced..b6c61e7bad7 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -69,109 +69,100 @@ describe('Issue card component', () => { }); it('renders issue title', () => { - expect( - component.$el.querySelector('.board-card-title').textContent, - ).toContain(issue.title); + expect(component.$el.querySelector('.board-card-title').textContent).toContain(issue.title); }); it('includes issue base in link', () => { - expect( - component.$el.querySelector('.board-card-title a').getAttribute('href'), - ).toContain('/test'); + expect(component.$el.querySelector('.board-card-title a').getAttribute('href')).toContain( + '/test', + ); }); it('includes issue title on link', () => { - expect( - component.$el.querySelector('.board-card-title a').getAttribute('title'), - ).toBe(issue.title); + expect(component.$el.querySelector('.board-card-title a').getAttribute('title')).toBe( + issue.title, + ); }); it('does not render confidential icon', () => { - expect( - component.$el.querySelector('.fa-eye-flash'), - ).toBeNull(); + expect(component.$el.querySelector('.fa-eye-flash')).toBeNull(); }); - it('renders confidential icon', (done) => { + it('renders confidential icon', done => { component.issue.confidential = true; Vue.nextTick(() => { - expect( - component.$el.querySelector('.confidential-icon'), - ).not.toBeNull(); + expect(component.$el.querySelector('.confidential-icon')).not.toBeNull(); done(); }); }); it('renders issue ID with #', () => { - expect( - component.$el.querySelector('.board-card-number').textContent, - ).toContain(`#${issue.id}`); + expect(component.$el.querySelector('.board-card-number').textContent).toContain(`#${issue.id}`); }); describe('assignee', () => { it('does not render assignee', () => { - expect( - component.$el.querySelector('.board-card-assignee .avatar'), - ).toBeNull(); + expect(component.$el.querySelector('.board-card-assignee .avatar')).toBeNull(); }); describe('exists', () => { - beforeEach((done) => { + beforeEach(done => { component.issue.assignees = [user]; Vue.nextTick(() => done()); }); it('renders assignee', () => { - expect( - component.$el.querySelector('.board-card-assignee .avatar'), - ).not.toBeNull(); + expect(component.$el.querySelector('.board-card-assignee .avatar')).not.toBeNull(); }); it('sets title', () => { expect( - component.$el.querySelector('.board-card-assignee img').getAttribute('data-original-title'), + component.$el + .querySelector('.board-card-assignee img') + .getAttribute('data-original-title'), ).toContain(`Assigned to ${user.name}`); }); it('sets users path', () => { - expect( - component.$el.querySelector('.board-card-assignee a').getAttribute('href'), - ).toBe('/test'); + expect(component.$el.querySelector('.board-card-assignee a').getAttribute('href')).toBe( + '/test', + ); }); it('renders avatar', () => { - expect( - component.$el.querySelector('.board-card-assignee img'), - ).not.toBeNull(); + expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull(); }); }); describe('assignee default avatar', () => { - beforeEach((done) => { - component.issue.assignees = [new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - }, 'default_avatar')]; + beforeEach(done => { + component.issue.assignees = [ + new ListAssignee( + { + id: 1, + name: 'testing 123', + username: 'test', + }, + 'default_avatar', + ), + ]; Vue.nextTick(done); }); it('displays defaults avatar if users avatar is null', () => { - expect( - component.$el.querySelector('.board-card-assignee img'), - ).not.toBeNull(); - expect( - component.$el.querySelector('.board-card-assignee img').getAttribute('src'), - ).toBe('default_avatar'); + expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull(); + expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe( + 'default_avatar?width=20', + ); }); }); }); describe('multiple assignees', () => { - beforeEach((done) => { + beforeEach(done => { component.issue.assignees = [ user, new ListAssignee({ @@ -191,7 +182,8 @@ describe('Issue card component', () => { name: 'user4', username: 'user4', avatar: 'test_image', - })]; + }), + ]; Vue.nextTick(() => done()); }); @@ -201,26 +193,30 @@ describe('Issue card component', () => { }); describe('more than four assignees', () => { - beforeEach((done) => { - component.issue.assignees.push(new ListAssignee({ - id: 5, - name: 'user5', - username: 'user5', - avatar: 'test_image', - })); + beforeEach(done => { + component.issue.assignees.push( + new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + }), + ); Vue.nextTick(() => done()); }); it('renders more avatar counter', () => { - expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('+2'); + expect( + component.$el.querySelector('.board-card-assignee .avatar-counter').innerText, + ).toEqual('+2'); }); it('renders three assignees', () => { expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3); }); - it('renders 99+ avatar counter', (done) => { + it('renders 99+ avatar counter', done => { for (let i = 5; i < 104; i += 1) { const u = new ListAssignee({ id: i, @@ -232,7 +228,9 @@ describe('Issue card component', () => { } Vue.nextTick(() => { - expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('99+'); + expect( + component.$el.querySelector('.board-card-assignee .avatar-counter').innerText, + ).toEqual('99+'); done(); }); }); @@ -240,59 +238,51 @@ describe('Issue card component', () => { }); describe('labels', () => { - beforeEach((done) => { + beforeEach(done => { component.issue.addLabel(label1); Vue.nextTick(() => done()); }); it('renders list label', () => { - expect( - component.$el.querySelectorAll('.badge').length, - ).toBe(2); + expect(component.$el.querySelectorAll('.badge').length).toBe(2); }); it('renders label', () => { const nodes = []; - component.$el.querySelectorAll('.badge').forEach((label) => { + component.$el.querySelectorAll('.badge').forEach(label => { nodes.push(label.getAttribute('data-original-title')); }); - expect( - nodes.includes(label1.description), - ).toBe(true); + expect(nodes.includes(label1.description)).toBe(true); }); it('sets label description as title', () => { - expect( - component.$el.querySelector('.badge').getAttribute('data-original-title'), - ).toContain(label1.description); + expect(component.$el.querySelector('.badge').getAttribute('data-original-title')).toContain( + label1.description, + ); }); it('sets background color of button', () => { const nodes = []; - component.$el.querySelectorAll('.badge').forEach((label) => { + component.$el.querySelectorAll('.badge').forEach(label => { nodes.push(label.style.backgroundColor); }); - expect( - nodes.includes(label1.color), - ).toBe(true); + expect(nodes.includes(label1.color)).toBe(true); }); - it('does not render label if label does not have an ID', (done) => { - component.issue.addLabel(new ListLabel({ - title: 'closed', - })); + it('does not render label if label does not have an ID', done => { + component.issue.addLabel( + new ListLabel({ + title: 'closed', + }), + ); Vue.nextTick() .then(() => { - expect( - component.$el.querySelectorAll('.badge').length, - ).toBe(2); - expect( - component.$el.textContent, - ).not.toContain('closed'); + expect(component.$el.querySelectorAll('.badge').length).toBe(2); + expect(component.$el.textContent).not.toContain('closed'); done(); }) diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index 839b8a06b48..d0e0b214509 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -1,11 +1,9 @@ import Clusters from '~/clusters/clusters_bundle'; import { - APPLICATION_INSTALLABLE, - APPLICATION_INSTALLING, - APPLICATION_INSTALLED, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE, + APPLICATION_STATUS, } from '~/clusters/constants'; import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; @@ -84,7 +82,7 @@ describe('Clusters', () => { it('does not show alert when things transition from initial null state to something', () => { cluster.checkForNewInstalls(INITIAL_APP_MAP, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_INSTALLABLE, title: 'Helm Tiller' }, + helm: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Helm Tiller' }, }); const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); @@ -94,10 +92,10 @@ describe('Clusters', () => { it('shows an alert when something gets newly installed', () => { cluster.checkForNewInstalls({ ...INITIAL_APP_MAP, - helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' }, }, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' }, }); const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); @@ -108,12 +106,12 @@ describe('Clusters', () => { it('shows an alert when multiple things gets newly installed', () => { cluster.checkForNewInstalls({ ...INITIAL_APP_MAP, - helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, - ingress: { status: APPLICATION_INSTALLABLE, title: 'Ingress' }, + helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' }, + ingress: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Ingress' }, }, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, - ingress: { status: APPLICATION_INSTALLED, title: 'Ingress' }, + helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' }, + ingress: { status: APPLICATION_STATUS.INSTALLED, title: 'Ingress' }, }); const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index c83cbe90a57..9da5c248371 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -1,12 +1,7 @@ import Vue from 'vue'; import eventHub from '~/clusters/event_hub'; import { - APPLICATION_NOT_INSTALLABLE, - APPLICATION_SCHEDULED, - APPLICATION_INSTALLABLE, - APPLICATION_INSTALLING, - APPLICATION_INSTALLED, - APPLICATION_ERROR, + APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE, @@ -62,10 +57,10 @@ describe('Application Row', () => { expect(vm.installButtonLabel).toBeUndefined(); }); - it('has disabled "Install" when APPLICATION_NOT_INSTALLABLE', () => { + it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_NOT_INSTALLABLE, + status: APPLICATION_STATUS.NOT_INSTALLABLE, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -73,10 +68,10 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has enabled "Install" when APPLICATION_INSTALLABLE', () => { + it('has enabled "Install" when APPLICATION_STATUS.INSTALLABLE', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -84,10 +79,10 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(false); }); - it('has loading "Installing" when APPLICATION_SCHEDULED', () => { + it('has loading "Installing" when APPLICATION_STATUS.SCHEDULED', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_SCHEDULED, + status: APPLICATION_STATUS.SCHEDULED, }); expect(vm.installButtonLabel).toEqual('Installing'); @@ -95,10 +90,10 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has loading "Installing" when APPLICATION_INSTALLING', () => { + it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLING, + status: APPLICATION_STATUS.INSTALLING, }); expect(vm.installButtonLabel).toEqual('Installing'); @@ -106,10 +101,10 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has disabled "Installed" when APPLICATION_INSTALLED', () => { + it('has disabled "Installed" when APPLICATION_STATUS.INSTALLED', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLED, + status: APPLICATION_STATUS.INSTALLED, }); expect(vm.installButtonLabel).toEqual('Installed'); @@ -117,10 +112,10 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has enabled "Install" when APPLICATION_ERROR', () => { + it('has enabled "Install" when APPLICATION_STATUS.ERROR', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_ERROR, + status: APPLICATION_STATUS.ERROR, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -131,7 +126,7 @@ describe('Application Row', () => { it('has loading "Install" when REQUEST_LOADING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, requestStatus: REQUEST_LOADING, }); @@ -143,7 +138,7 @@ describe('Application Row', () => { it('has disabled "Install" when REQUEST_SUCCESS', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, requestStatus: REQUEST_SUCCESS, }); @@ -155,7 +150,7 @@ describe('Application Row', () => { it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, requestStatus: REQUEST_FAILURE, }); @@ -168,7 +163,7 @@ describe('Application Row', () => { spyOn(eventHub, '$emit'); vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, }); const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); @@ -184,7 +179,7 @@ describe('Application Row', () => { spyOn(eventHub, '$emit'); vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, installApplicationRequestParams: { hostname: 'jupyter' }, }); const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); @@ -201,7 +196,7 @@ describe('Application Row', () => { spyOn(eventHub, '$emit'); vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLING, + status: APPLICATION_STATUS.INSTALLING, }); const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); @@ -225,11 +220,11 @@ describe('Application Row', () => { expect(generalErrorMessage).toBeNull(); }); - it('shows status reason when APPLICATION_ERROR', () => { + it('shows status reason when APPLICATION_STATUS.ERROR', () => { const statusReason = 'We broke it 0.0'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_ERROR, + status: APPLICATION_STATUS.ERROR, statusReason, }); const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); @@ -243,7 +238,7 @@ describe('Application Row', () => { const requestReason = 'We broke thre request 0.0'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, requestStatus: REQUEST_FAILURE, requestReason, }); diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js index b2b0ebf840b..c7c1412e1c6 100644 --- a/spec/javascripts/clusters/services/mock_data.js +++ b/spec/javascripts/clusters/services/mock_data.js @@ -1,9 +1,4 @@ -import { - APPLICATION_INSTALLED, - APPLICATION_INSTALLABLE, - APPLICATION_INSTALLING, - APPLICATION_ERROR, -} from '~/clusters/constants'; +import { APPLICATION_STATUS } from '~/clusters/constants'; const CLUSTERS_MOCK_DATA = { GET: { @@ -13,25 +8,25 @@ const CLUSTERS_MOCK_DATA = { status_reason: 'Failed to request to CloudPlatform.', applications: [{ name: 'helm', - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, status_reason: null, }, { name: 'ingress', - status: APPLICATION_ERROR, + status: APPLICATION_STATUS.ERROR, status_reason: 'Cannot connect', external_ip: null, }, { name: 'runner', - status: APPLICATION_INSTALLING, + status: APPLICATION_STATUS.INSTALLING, status_reason: null, }, { name: 'prometheus', - status: APPLICATION_ERROR, + status: APPLICATION_STATUS.ERROR, status_reason: 'Cannot connect', }, { name: 'jupyter', - status: APPLICATION_INSTALLING, + status: APPLICATION_STATUS.INSTALLING, status_reason: 'Cannot connect', }], }, @@ -42,25 +37,25 @@ const CLUSTERS_MOCK_DATA = { status_reason: 'Failed to request to CloudPlatform.', applications: [{ name: 'helm', - status: APPLICATION_INSTALLED, + status: APPLICATION_STATUS.INSTALLED, status_reason: null, }, { name: 'ingress', - status: APPLICATION_INSTALLED, + status: APPLICATION_STATUS.INSTALLED, status_reason: 'Cannot connect', external_ip: '1.1.1.1', }, { name: 'runner', - status: APPLICATION_INSTALLING, + status: APPLICATION_STATUS.INSTALLING, status_reason: null, }, { name: 'prometheus', - status: APPLICATION_ERROR, + status: APPLICATION_STATUS.ERROR, status_reason: 'Cannot connect', }, { name: 'jupyter', - status: APPLICATION_INSTALLABLE, + status: APPLICATION_STATUS.INSTALLABLE, status_reason: 'Cannot connect', }], }, diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js index 9e43552f740..104a064bdd3 100644 --- a/spec/javascripts/clusters/stores/clusters_store_spec.js +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -1,5 +1,5 @@ import ClustersStore from '~/clusters/stores/clusters_store'; -import { APPLICATION_INSTALLING } from '~/clusters/constants'; +import { APPLICATION_STATUS } from '~/clusters/constants'; import { CLUSTERS_MOCK_DATA } from '../services/mock_data'; describe('Clusters Store', () => { @@ -35,7 +35,7 @@ describe('Clusters Store', () => { it('should store new request status', () => { expect(store.state.applications.helm.requestStatus).toEqual(null); - const newStatus = APPLICATION_INSTALLING; + const newStatus = APPLICATION_STATUS.INSTALLING; store.updateAppProperty('helm', 'requestStatus', newStatus); expect(store.state.applications.helm.requestStatus).toEqual(newStatus); diff --git a/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js b/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js index 2d136a63c52..a1a37b342b7 100644 --- a/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js +++ b/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js @@ -48,7 +48,11 @@ describe('DiffLineGutterContent', () => { it('should return discussions for the given lineCode', () => { const { lineCode } = getDiffFileMock().highlightedDiffLines[1]; - const component = createComponent({ lineCode, showCommentButton: true }); + const component = createComponent({ + lineCode, + showCommentButton: true, + discussions: getDiscussionsMockData(), + }); setDiscussions(component); diff --git a/spec/javascripts/diffs/components/diff_line_note_form_spec.js b/spec/javascripts/diffs/components/diff_line_note_form_spec.js index 4600aaea70b..6fe5fdaf7f9 100644 --- a/spec/javascripts/diffs/components/diff_line_note_form_spec.js +++ b/spec/javascripts/diffs/components/diff_line_note_form_spec.js @@ -3,6 +3,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import store from '~/mr_notes/stores'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import diffFileMockData from '../mock_data/diff_file'; +import { noteableDataMock } from '../../notes/mock_data'; describe('DiffLineNoteForm', () => { let component; @@ -21,10 +22,9 @@ describe('DiffLineNoteForm', () => { noteTargetLine: diffLines[0], }); - Object.defineProperty(component, 'isLoggedIn', { - get() { - return true; - }, + Object.defineProperties(component, { + noteableData: { value: noteableDataMock }, + isLoggedIn: { value: true }, }); component.$mount(); @@ -32,12 +32,37 @@ describe('DiffLineNoteForm', () => { describe('methods', () => { describe('handleCancelCommentForm', () => { - it('should call cancelCommentForm with lineCode', () => { + it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => { + spyOn(window, 'confirm').and.returnValue(false); + + component.handleCancelCommentForm(true, true); + expect(window.confirm).toHaveBeenCalled(); + }); + + it('should ask for confirmation when one of the params false', () => { + spyOn(window, 'confirm').and.returnValue(false); + + component.handleCancelCommentForm(true, false); + expect(window.confirm).not.toHaveBeenCalled(); + + component.handleCancelCommentForm(false, true); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('should call cancelCommentForm with lineCode', done => { + spyOn(window, 'confirm'); spyOn(component, 'cancelCommentForm'); + spyOn(component, 'resetAutoSave'); component.handleCancelCommentForm(); - expect(component.cancelCommentForm).toHaveBeenCalledWith({ - lineCode: diffLines[0].lineCode, + expect(window.confirm).not.toHaveBeenCalled(); + component.$nextTick(() => { + expect(component.cancelCommentForm).toHaveBeenCalledWith({ + lineCode: diffLines[0].lineCode, + }); + expect(component.resetAutoSave).toHaveBeenCalled(); + + done(); }); }); }); @@ -66,7 +91,7 @@ describe('DiffLineNoteForm', () => { describe('mounted', () => { it('should init autosave', () => { - const key = 'autosave/Note/issue///DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1'; + const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1'; expect(component.autosave).toBeDefined(); expect(component.autosave.key).toEqual(key); diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js index 7706c32d24d..a59b26b2634 100644 --- a/spec/javascripts/diffs/store/getters_spec.js +++ b/spec/javascripts/diffs/store/getters_spec.js @@ -184,6 +184,104 @@ describe('Diffs Module Getters', () => { }); }); + describe('singleDiscussionByLineCode', () => { + it('returns found discussion per line Code', () => { + const discussionsMock = {}; + discussionsMock.ABC = discussionMock; + + expect( + getters.singleDiscussionByLineCode(localState, {}, null, { + discussionsByLineCode: () => discussionsMock, + })('DEF'), + ).toEqual([]); + }); + + it('returns empty array when no discussions match', () => { + expect( + getters.singleDiscussionByLineCode(localState, {}, null, { + discussionsByLineCode: () => {}, + })('DEF'), + ).toEqual([]); + }); + }); + + describe('shouldRenderParallelCommentRow', () => { + let line; + + beforeEach(() => { + line = {}; + + line.left = { + lineCode: 'ABC', + }; + + line.right = { + lineCode: 'DEF', + }; + }); + + it('returns true when discussion is expanded', () => { + discussionMock.expanded = true; + + expect( + getters.shouldRenderParallelCommentRow(localState, { + singleDiscussionByLineCode: () => [discussionMock], + })(line), + ).toEqual(true); + }); + + it('returns false when no discussion was found', () => { + localState.diffLineCommentForms.ABC = false; + localState.diffLineCommentForms.DEF = false; + + expect( + getters.shouldRenderParallelCommentRow(localState, { + singleDiscussionByLineCode: () => [], + })(line), + ).toEqual(false); + }); + + it('returns true when discussionForm was found', () => { + localState.diffLineCommentForms.ABC = {}; + + expect( + getters.shouldRenderParallelCommentRow(localState, { + singleDiscussionByLineCode: () => [discussionMock], + })(line), + ).toEqual(true); + }); + }); + + describe('shouldRenderInlineCommentRow', () => { + it('returns true when diffLineCommentForms has form', () => { + localState.diffLineCommentForms.ABC = {}; + + expect( + getters.shouldRenderInlineCommentRow(localState)({ + lineCode: 'ABC', + }), + ).toEqual(true); + }); + + it('returns false when no line discussions were found', () => { + expect( + getters.shouldRenderInlineCommentRow(localState, { + singleDiscussionByLineCode: () => [], + })('DEF'), + ).toEqual(false); + }); + + it('returns true if all found discussions are expanded', () => { + discussionMock.expanded = true; + + expect( + getters.shouldRenderInlineCommentRow(localState, { + singleDiscussionByLineCode: () => [discussionMock], + })('ABC'), + ).toEqual(true); + }); + }); + describe('getDiffFileDiscussions', () => { it('returns an array with discussions when fileHash matches and the discussion belongs to a diff', () => { discussionMock.diff_file.file_hash = diffFileMock.fileHash; diff --git a/spec/javascripts/fixtures/search_autocomplete.html.haml b/spec/javascripts/fixtures/search_autocomplete.html.haml index 0421ed2182f..4aa54da9411 100644 --- a/spec/javascripts/fixtures/search_autocomplete.html.haml +++ b/spec/javascripts/fixtures/search_autocomplete.html.haml @@ -1,8 +1,6 @@ -.search.search-form.has-location-badge - %form.navbar-form +.search.search-form + %form.form-inline .search-input-container - %div.location-badge - This project .search-input-wrap .dropdown %input#search.search-input.dropdown-menu-toggle diff --git a/spec/javascripts/helpers/vuex_action_helper.js b/spec/javascripts/helpers/vuex_action_helper.js index dd9174194a1..1972408356e 100644 --- a/spec/javascripts/helpers/vuex_action_helper.js +++ b/spec/javascripts/helpers/vuex_action_helper.js @@ -84,7 +84,7 @@ export default ( done(); }; - const result = action({ commit, state, dispatch, rootState: state }, payload); + const result = action({ commit, state, dispatch, rootState: state, rootGetters: state }, payload); return new Promise(resolve => { setImmediate(resolve); diff --git a/spec/javascripts/ide/components/activity_bar_spec.js b/spec/javascripts/ide/components/activity_bar_spec.js index 946c7e8e9c8..4d878e633fe 100644 --- a/spec/javascripts/ide/components/activity_bar_spec.js +++ b/spec/javascripts/ide/components/activity_bar_spec.js @@ -24,26 +24,6 @@ describe('IDE activity bar', () => { resetStore(vm.$store); }); - describe('goBackUrl', () => { - it('renders the Go Back link with the referrer when present', () => { - const fakeReferrer = '/example/README.md'; - spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); - - vm.$mount(); - - expect(vm.goBackUrl).toEqual(fakeReferrer); - }); - - it('renders the Go Back link with the project url when referrer is not present', () => { - const fakeReferrer = ''; - spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); - - vm.$mount(); - - expect(vm.goBackUrl).toEqual('testing'); - }); - }); - describe('updateActivityBarView', () => { beforeEach(() => { spyOn(vm, 'updateActivityBarView'); diff --git a/spec/javascripts/ide/components/branches/item_spec.js b/spec/javascripts/ide/components/branches/item_spec.js new file mode 100644 index 00000000000..8b756c8f168 --- /dev/null +++ b/spec/javascripts/ide/components/branches/item_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import mountCompontent from 'spec/helpers/vue_mount_component_helper'; +import router from '~/ide/ide_router'; +import Item from '~/ide/components/branches/item.vue'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { projectData } from '../../mock_data'; + +const TEST_BRANCH = { + name: 'master', + committedDate: '2018-01-05T05:50Z', +}; +const TEST_PROJECT_ID = projectData.name_with_namespace; + +describe('IDE branch item', () => { + const Component = Vue.extend(Item); + let vm; + + beforeEach(() => { + vm = mountCompontent(Component, { + item: { ...TEST_BRANCH }, + projectId: TEST_PROJECT_ID, + isActive: false, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders branch name and timeago', () => { + const timeText = getTimeago().format(TEST_BRANCH.committedDate); + expect(vm.$el).toContainText(TEST_BRANCH.name); + expect(vm.$el.querySelector('time')).toHaveText(timeText); + expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null); + }); + + it('renders link to branch', () => { + const expectedHref = router.resolve(`/project/${TEST_PROJECT_ID}/edit/${TEST_BRANCH.name}`).href; + expect(vm.$el).toMatch('a'); + expect(vm.$el).toHaveAttr('href', expectedHref); + }); + + it('renders icon if isActive', done => { + vm.isActive = true; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/javascripts/ide/components/branches/search_list_spec.js b/spec/javascripts/ide/components/branches/search_list_spec.js new file mode 100644 index 00000000000..c3f84ba1c24 --- /dev/null +++ b/spec/javascripts/ide/components/branches/search_list_spec.js @@ -0,0 +1,79 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import * as types from '~/ide/stores/modules/branches/mutation_types'; +import List from '~/ide/components/branches/search_list.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { branches as testBranches } from '../../mock_data'; +import { resetStore } from '../../helpers'; + +describe('IDE branches search list', () => { + const Component = Vue.extend(List); + let vm; + + beforeEach(() => { + vm = createComponentWithStore(Component, store, {}); + + spyOn(vm, 'fetchBranches'); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(store); + }); + + it('calls fetch on mounted', () => { + expect(vm.fetchBranches).toHaveBeenCalledWith({ + search: '', + }); + }); + + it('renders loading icon', done => { + vm.$store.state.branches.isLoading = true; + + vm.$nextTick() + .then(() => { + expect(vm.$el).toContainElement('.loading-container'); + }) + .then(done) + .catch(done.fail); + }); + + it('renders branches not found when search is not empty', done => { + vm.search = 'testing'; + + vm.$nextTick(() => { + expect(vm.$el).toContainText('No branches found'); + + done(); + }); + }); + + describe('with branches', () => { + const currentBranch = testBranches[1]; + + beforeEach(done => { + vm.$store.state.currentBranchId = currentBranch.name; + vm.$store.commit(`branches/${types.RECEIVE_BRANCHES_SUCCESS}`, testBranches); + + vm.$nextTick(done); + }); + + it('renders list', () => { + const elementText = Array.from(vm.$el.querySelectorAll('li strong')) + .map(x => x.textContent.trim()); + + expect(elementText).toEqual(testBranches.map(x => x.name)); + }); + + it('renders check next to active branch', () => { + const checkedText = Array.from(vm.$el.querySelectorAll('li')) + .filter(x => x.querySelector('.ide-search-list-current-icon svg')) + .map(x => x.querySelector('strong').textContent.trim()); + + expect(checkedText).toEqual([currentBranch.name]); + }); + }); +}); diff --git a/spec/javascripts/ide/components/merge_requests/dropdown_spec.js b/spec/javascripts/ide/components/merge_requests/dropdown_spec.js deleted file mode 100644 index 74884c9a362..00000000000 --- a/spec/javascripts/ide/components/merge_requests/dropdown_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import Vue from 'vue'; -import { createStore } from '~/ide/stores'; -import Dropdown from '~/ide/components/merge_requests/dropdown.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; -import { mergeRequests } from '../../mock_data'; - -describe('IDE merge requests dropdown', () => { - const Component = Vue.extend(Dropdown); - let vm; - - beforeEach(() => { - const store = createStore(); - - vm = createComponentWithStore(Component, store, { show: false }).$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('does not render tabs when show is false', () => { - expect(vm.$el.querySelector('.nav-links')).toBe(null); - }); - - describe('when show is true', () => { - beforeEach(done => { - vm.show = true; - vm.$store.state.mergeRequests.assigned.mergeRequests.push(mergeRequests[0]); - - vm.$nextTick(done); - }); - - it('renders tabs', () => { - expect(vm.$el.querySelector('.nav-links')).not.toBe(null); - }); - - it('renders count for assigned & created data', () => { - expect(vm.$el.querySelector('.nav-links a').textContent).toContain('Created by me'); - expect(vm.$el.querySelector('.nav-links a .badge').textContent).toContain('0'); - - expect(vm.$el.querySelectorAll('.nav-links a')[1].textContent).toContain('Assigned to me'); - expect( - vm.$el.querySelectorAll('.nav-links a')[1].querySelector('.badge').textContent, - ).toContain('1'); - }); - }); -}); diff --git a/spec/javascripts/ide/components/merge_requests/item_spec.js b/spec/javascripts/ide/components/merge_requests/item_spec.js index 51c4cddef2f..750948cae3c 100644 --- a/spec/javascripts/ide/components/merge_requests/item_spec.js +++ b/spec/javascripts/ide/components/merge_requests/item_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import router from '~/ide/ide_router'; import Item from '~/ide/components/merge_requests/item.vue'; import mountCompontent from '../../../helpers/vue_mount_component_helper'; @@ -27,6 +28,12 @@ describe('IDE merge request item', () => { expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1'); }); + it('renders link with href', () => { + const expectedHref = router.resolve(`/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`).href; + expect(vm.$el).toMatch('a'); + expect(vm.$el).toHaveAttr('href', expectedHref); + }); + it('renders icon if ID matches currentId', () => { expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); }); @@ -50,12 +57,4 @@ describe('IDE merge request item', () => { done(); }); }); - - it('emits click event on click', () => { - spyOn(vm, '$emit'); - - vm.$el.click(); - - expect(vm.$emit).toHaveBeenCalledWith('click', vm.item); - }); }); diff --git a/spec/javascripts/ide/components/merge_requests/list_spec.js b/spec/javascripts/ide/components/merge_requests/list_spec.js index f4b393778dc..c761315444c 100644 --- a/spec/javascripts/ide/components/merge_requests/list_spec.js +++ b/spec/javascripts/ide/components/merge_requests/list_spec.js @@ -10,10 +10,7 @@ describe('IDE merge requests list', () => { let vm; beforeEach(() => { - vm = createComponentWithStore(Component, store, { - type: 'created', - emptyText: 'empty text', - }); + vm = createComponentWithStore(Component, store, {}); spyOn(vm, 'fetchMergeRequests'); @@ -28,13 +25,13 @@ describe('IDE merge requests list', () => { it('calls fetch on mounted', () => { expect(vm.fetchMergeRequests).toHaveBeenCalledWith({ - type: 'created', search: '', + type: '', }); }); it('renders loading icon', done => { - vm.$store.state.mergeRequests.created.isLoading = true; + vm.$store.state.mergeRequests.isLoading = true; vm.$nextTick(() => { expect(vm.$el.querySelector('.loading-container')).not.toBe(null); @@ -43,10 +40,6 @@ describe('IDE merge requests list', () => { }); }); - it('renders empty text when no merge requests exist', () => { - expect(vm.$el.textContent).toContain('empty text'); - }); - it('renders no search results text when search is not empty', done => { vm.search = 'testing'; @@ -57,9 +50,29 @@ describe('IDE merge requests list', () => { }); }); + it('clicking on search type, sets currentSearchType and loads merge requests', done => { + vm.onSearchFocus(); + + vm.$nextTick() + .then(() => { + vm.$el.querySelector('li button').click(); + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.currentSearchType).toEqual(vm.$options.searchTypes[0]); + expect(vm.fetchMergeRequests).toHaveBeenCalledWith({ + type: vm.currentSearchType.type, + search: '', + }); + }) + .then(done) + .catch(done.fail); + }); + describe('with merge requests', () => { beforeEach(done => { - vm.$store.state.mergeRequests.created.mergeRequests.push({ + vm.$store.state.mergeRequests.mergeRequests.push({ ...mergeRequests[0], projectPathWithNamespace: 'gitlab-org/gitlab-ce', }); @@ -71,35 +84,6 @@ describe('IDE merge requests list', () => { expect(vm.$el.querySelectorAll('li').length).toBe(1); expect(vm.$el.querySelector('li').textContent).toContain(mergeRequests[0].title); }); - - it('calls openMergeRequest when clicking merge request', done => { - spyOn(vm, 'openMergeRequest'); - vm.$el.querySelector('li button').click(); - - vm.$nextTick(() => { - expect(vm.openMergeRequest).toHaveBeenCalledWith({ - projectPath: 'gitlab-org/gitlab-ce', - id: 1, - }); - - done(); - }); - }); - }); - - describe('focusSearch', () => { - it('focuses search input when loading is false', done => { - spyOn(vm.$refs.searchInput, 'focus'); - - vm.$store.state.mergeRequests.created.isLoading = false; - vm.focusSearch(); - - vm.$nextTick(() => { - expect(vm.$refs.searchInput.focus).toHaveBeenCalled(); - - done(); - }); - }); }); describe('searchMergeRequests', () => { @@ -123,4 +107,52 @@ describe('IDE merge requests list', () => { expect(vm.loadMergeRequests).toHaveBeenCalled(); }); }); + + describe('onSearchFocus', () => { + it('shows search types', done => { + vm.$el.querySelector('input').dispatchEvent(new Event('focus')); + + expect(vm.hasSearchFocus).toBe(true); + expect(vm.showSearchTypes).toBe(true); + + vm.$nextTick() + .then(() => { + const expectedSearchTypes = vm.$options.searchTypes.map(x => x.label); + const renderedSearchTypes = Array.from(vm.$el.querySelectorAll('li')) + .map(x => x.textContent.trim()); + + expect(renderedSearchTypes).toEqual(expectedSearchTypes); + }) + .then(done) + .catch(done.fail); + }); + + it('does not show search types, if already has search value', () => { + vm.search = 'lorem ipsum'; + vm.$el.querySelector('input').dispatchEvent(new Event('focus')); + + expect(vm.hasSearchFocus).toBe(true); + expect(vm.showSearchTypes).toBe(false); + }); + + it('does not show search types, if already has a search type', () => { + vm.currentSearchType = {}; + vm.$el.querySelector('input').dispatchEvent(new Event('focus')); + + expect(vm.hasSearchFocus).toBe(true); + expect(vm.showSearchTypes).toBe(false); + }); + + it('resets hasSearchFocus when search changes', done => { + vm.hasSearchFocus = true; + vm.search = 'something else'; + + vm.$nextTick() + .then(() => { + expect(vm.hasSearchFocus).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/ide/components/nav_dropdown_button_spec.js b/spec/javascripts/ide/components/nav_dropdown_button_spec.js new file mode 100644 index 00000000000..0a58e260280 --- /dev/null +++ b/spec/javascripts/ide/components/nav_dropdown_button_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue'; +import store from '~/ide/stores'; +import { trimText } from 'spec/helpers/vue_component_helper'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('NavDropdown', () => { + const TEST_BRANCH_ID = 'lorem-ipsum-dolar'; + const TEST_MR_ID = '12345'; + const Component = Vue.extend(NavDropdownButton); + let vm; + + beforeEach(() => { + vm = mountComponentWithStore(Component, { store }); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(store); + }); + + it('renders empty placeholders, if state is falsey', () => { + expect(trimText(vm.$el.textContent)).toEqual('- -'); + }); + + it('renders branch name, if state has currentBranchId', done => { + vm.$store.state.currentBranchId = TEST_BRANCH_ID; + + vm.$nextTick() + .then(() => { + expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`); + }) + .then(done) + .catch(done.fail); + }); + + it('renders mr id, if state has currentMergeRequestId', done => { + vm.$store.state.currentMergeRequestId = TEST_MR_ID; + + vm.$nextTick() + .then(() => { + expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`); + }) + .then(done) + .catch(done.fail); + }); + + it('renders branch and mr, if state has both', done => { + vm.$store.state.currentBranchId = TEST_BRANCH_ID; + vm.$store.state.currentMergeRequestId = TEST_MR_ID; + + vm.$nextTick() + .then(() => { + expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/javascripts/ide/components/nav_dropdown_spec.js b/spec/javascripts/ide/components/nav_dropdown_spec.js new file mode 100644 index 00000000000..af6665bcd62 --- /dev/null +++ b/spec/javascripts/ide/components/nav_dropdown_spec.js @@ -0,0 +1,50 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import store from '~/ide/stores'; +import NavDropdown from '~/ide/components/nav_dropdown.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE NavDropdown', () => { + const Component = Vue.extend(NavDropdown); + let vm; + let $dropdown; + + beforeEach(() => { + vm = mountComponentWithStore(Component, { store }); + $dropdown = $(vm.$el); + + // block dispatch from doing anything + spyOn(vm.$store, 'dispatch'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders nothing initially', () => { + expect(vm.$el).not.toContainElement('.ide-nav-form'); + }); + + it('renders nav form when show.bs.dropdown', done => { + $dropdown.trigger('show.bs.dropdown'); + + vm.$nextTick() + .then(() => { + expect(vm.$el).toContainElement('.ide-nav-form'); + }) + .then(done) + .catch(done.fail); + }); + + it('destroys nav form when closed', done => { + $dropdown.trigger('show.bs.dropdown'); + $dropdown.trigger('hide.bs.dropdown'); + + vm.$nextTick() + .then(() => { + expect(vm.$el).not.toContainElement('.ide-nav-form'); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/javascripts/ide/components/panes/right_spec.js b/spec/javascripts/ide/components/panes/right_spec.js index 99879fb0930..c75975d2af6 100644 --- a/spec/javascripts/ide/components/panes/right_spec.js +++ b/spec/javascripts/ide/components/panes/right_spec.js @@ -69,4 +69,17 @@ describe('IDE right pane', () => { }); }); }); + + describe('live preview', () => { + it('renders live preview button', done => { + Vue.set(vm.$store.state.entries, 'package.json', { name: 'package.json' }); + vm.$store.state.clientsidePreviewEnabled = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('button[aria-label="Live preview"]')).not.toBeNull(); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/ide/components/preview/clientside_spec.js b/spec/javascripts/ide/components/preview/clientside_spec.js new file mode 100644 index 00000000000..3ec65882418 --- /dev/null +++ b/spec/javascripts/ide/components/preview/clientside_spec.js @@ -0,0 +1,362 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { createStore } from '~/ide/stores'; +import Clientside from '~/ide/components/preview/clientside.vue'; +import timeoutPromise from 'spec/helpers/set_timeout_promise_helper'; +import { resetStore, file } from '../../helpers'; + +describe('IDE clientside preview', () => { + let vm; + let Component; + + beforeAll(() => { + Component = Vue.extend(Clientside); + }); + + beforeEach(done => { + const store = createStore(); + + Vue.set(store.state.entries, 'package.json', { + ...file('package.json'), + }); + Vue.set(store.state, 'currentProjectId', 'gitlab-ce'); + Vue.set(store.state.projects, 'gitlab-ce', { + visibility: 'public', + }); + + vm = createComponentWithStore(Component, store); + + spyOn(vm, 'getFileData').and.returnValue(Promise.resolve()); + spyOn(vm, 'getRawFileData').and.returnValue(Promise.resolve('')); + spyOn(vm, 'initManager'); + + vm.$mount(); + + timeoutPromise() + .then(() => vm.$nextTick()) + .then(done) + .catch(done.fail); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + }); + + describe('without main entry', () => { + it('creates sandpack manager', () => { + expect(vm.initManager).not.toHaveBeenCalled(); + }); + }); + + describe('with main entry', () => { + beforeEach(done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + + vm + .$nextTick() + .then(() => vm.initPreview()) + .then(vm.$nextTick) + .then(done) + .catch(done.fail); + }); + + it('creates sandpack manager', () => { + expect(vm.initManager).toHaveBeenCalledWith( + '#ide-preview', + { + files: jasmine.any(Object), + entry: '/index.js', + showOpenInCodeSandbox: true, + }, + { + fileResolver: { + isFile: jasmine.any(Function), + readFile: jasmine.any(Function), + }, + }, + ); + }); + }); + + describe('computed', () => { + describe('normalizedEntries', () => { + beforeEach(done => { + vm.$store.state.entries['index.js'] = { + ...file('index.js'), + type: 'blob', + raw: 'test', + }; + vm.$store.state.entries['index2.js'] = { + ...file('index2.js'), + type: 'blob', + content: 'content', + }; + vm.$store.state.entries.tree = { + ...file('tree'), + type: 'tree', + }; + vm.$store.state.entries.empty = { + ...file('empty'), + type: 'blob', + }; + + vm.$nextTick(done); + }); + + it('returns flattened list of blobs with content', () => { + expect(vm.normalizedEntries).toEqual({ + '/index.js': { + code: 'test', + }, + '/index2.js': { + code: 'content', + }, + }); + }); + }); + + describe('mainEntry', () => { + it('returns false when package.json is empty', () => { + expect(vm.mainEntry).toBe(false); + }); + + it('returns main key from package.json', done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + + vm.$nextTick(() => { + expect(vm.mainEntry).toBe('index.js'); + + done(); + }); + }); + }); + + describe('showPreview', () => { + it('returns false if no mainEntry', () => { + expect(vm.showPreview).toBe(false); + }); + + it('returns false if loading', done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + vm.loading = true; + + vm.$nextTick(() => { + expect(vm.showPreview).toBe(false); + + done(); + }); + }); + + it('returns true if not loading and mainEntry exists', done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + vm.loading = false; + + vm.$nextTick(() => { + expect(vm.showPreview).toBe(true); + + done(); + }); + }); + }); + + describe('showEmptyState', () => { + it('returns true if no mainEnry exists', () => { + expect(vm.showEmptyState).toBe(true); + }); + + it('returns false if loading', done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + vm.loading = true; + + vm.$nextTick(() => { + expect(vm.showEmptyState).toBe(false); + + done(); + }); + }); + + it('returns false if not loading and mainEntry exists', done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + vm.loading = false; + + vm.$nextTick(() => { + expect(vm.showEmptyState).toBe(false); + + done(); + }); + }); + }); + + describe('showOpenInCodeSandbox', () => { + it('returns true when visiblity is public', () => { + expect(vm.showOpenInCodeSandbox).toBe(true); + }); + + it('returns false when visiblity is private', done => { + vm.$store.state.projects['gitlab-ce'].visibility = 'private'; + + vm.$nextTick(() => { + expect(vm.showOpenInCodeSandbox).toBe(false); + + done(); + }); + }); + }); + + describe('sandboxOpts', () => { + beforeEach(done => { + vm.$store.state.entries['index.js'] = { + ...file('index.js'), + type: 'blob', + raw: 'test', + }; + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + + vm.$nextTick(done); + }); + + it('returns sandbox options', () => { + expect(vm.sandboxOpts).toEqual({ + files: { + '/index.js': { + code: 'test', + }, + '/package.json': { + code: '{"main":"index.js"}', + }, + }, + entry: '/index.js', + showOpenInCodeSandbox: true, + }); + }); + }); + }); + + describe('methods', () => { + describe('loadFileContent', () => { + it('calls getFileData', () => { + expect(vm.getFileData).toHaveBeenCalledWith({ + path: 'package.json', + makeFileActive: false, + }); + }); + + it('calls getRawFileData', () => { + expect(vm.getRawFileData).toHaveBeenCalledWith({ path: 'package.json' }); + }); + }); + + describe('update', () => { + beforeEach(() => { + jasmine.clock().install(); + vm.manager.updatePreview = jasmine.createSpy('updatePreview'); + vm.manager.listener = jasmine.createSpy('updatePreview'); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('calls initPreview if manager is empty', () => { + spyOn(vm, 'initPreview'); + vm.manager = {}; + + vm.update(); + + jasmine.clock().tick(500); + + expect(vm.initPreview).toHaveBeenCalled(); + }); + + it('calls updatePreview', () => { + vm.update(); + + jasmine.clock().tick(500); + + expect(vm.manager.updatePreview).toHaveBeenCalledWith(vm.sandboxOpts); + }); + }); + }); + + describe('template', () => { + it('renders ide-preview element when showPreview is true', done => { + Vue.set( + vm.$store.state.entries['package.json'], + 'raw', + JSON.stringify({ + main: 'index.js', + }), + ); + vm.loading = false; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('#ide-preview')).not.toBe(null); + done(); + }); + }); + + it('renders empty state', done => { + vm.loading = false; + + vm.$nextTick(() => { + expect(vm.$el.textContent).toContain( + 'Preview your web application using Web IDE client-side evaluation.', + ); + + done(); + }); + }); + + it('renders loading icon', done => { + vm.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.loading-container')).not.toBe(null); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/preview/navigator_spec.js b/spec/javascripts/ide/components/preview/navigator_spec.js new file mode 100644 index 00000000000..576d2fae003 --- /dev/null +++ b/spec/javascripts/ide/components/preview/navigator_spec.js @@ -0,0 +1,185 @@ +import Vue from 'vue'; +import ClientsideNavigator from '~/ide/components/preview/navigator.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE clientside preview navigator', () => { + let vm; + let Component; + let manager; + + beforeAll(() => { + Component = Vue.extend(ClientsideNavigator); + }); + + beforeEach(() => { + manager = { + bundlerURL: gl.TEST_HOST, + iframe: { src: '' }, + }; + + vm = mountComponent(Component, { + manager, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders readonly URL bar', () => { + expect(vm.$el.querySelector('input[readonly]').value).toBe('/'); + }); + + it('disables back button when navigationStack is empty', () => { + expect(vm.$el.querySelector('.ide-navigator-btn')).toHaveAttr('disabled'); + expect(vm.$el.querySelector('.ide-navigator-btn').classList).toContain('disabled-content'); + }); + + it('disables forward button when forwardNavigationStack is empty', () => { + vm.forwardNavigationStack = []; + + expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1]).toHaveAttr('disabled'); + expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1].classList).toContain( + 'disabled-content', + ); + }); + + it('calls back method when clicking back button', done => { + vm.navigationStack.push('/test'); + vm.navigationStack.push('/test2'); + spyOn(vm, 'back'); + + vm.$nextTick(() => { + vm.$el.querySelector('.ide-navigator-btn').click(); + + expect(vm.back).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls forward method when clicking forward button', done => { + vm.forwardNavigationStack.push('/test'); + spyOn(vm, 'forward'); + + vm.$nextTick(() => { + vm.$el.querySelectorAll('.ide-navigator-btn')[1].click(); + + expect(vm.forward).toHaveBeenCalled(); + + done(); + }); + }); + + describe('onUrlChange', () => { + it('updates the path', () => { + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url`, + }); + + expect(vm.path).toBe('/url'); + }); + + it('sets currentBrowsingIndex 0 if not already set', () => { + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url`, + }); + + expect(vm.currentBrowsingIndex).toBe(0); + }); + + it('increases currentBrowsingIndex if path doesnt match', () => { + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url`, + }); + + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url2`, + }); + + expect(vm.currentBrowsingIndex).toBe(1); + }); + + it('does not increase currentBrowsingIndex if path matches', () => { + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url`, + }); + + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url`, + }); + + expect(vm.currentBrowsingIndex).toBe(0); + }); + + it('pushes path into navigation stack', () => { + vm.onUrlChange({ + url: `${gl.TEST_HOST}/url`, + }); + + expect(vm.navigationStack).toEqual(['/url']); + }); + }); + + describe('back', () => { + beforeEach(() => { + vm.path = '/test2'; + vm.currentBrowsingIndex = 1; + vm.navigationStack.push('/test'); + vm.navigationStack.push('/test2'); + + spyOn(vm, 'visitPath'); + + vm.back(); + }); + + it('visits the last entry in navigationStack', () => { + expect(vm.visitPath).toHaveBeenCalledWith('/test'); + }); + + it('adds last entry to forwardNavigationStack', () => { + expect(vm.forwardNavigationStack).toEqual(['/test2']); + }); + + it('clears navigation stack if currentBrowsingIndex is 1', () => { + expect(vm.navigationStack).toEqual([]); + }); + + it('sets currentBrowsingIndex to null is currentBrowsingIndex is 1', () => { + expect(vm.currentBrowsingIndex).toBe(null); + }); + }); + + describe('forward', () => { + it('calls visitPath with first entry in forwardNavigationStack', () => { + spyOn(vm, 'visitPath'); + + vm.forwardNavigationStack.push('/test'); + vm.forwardNavigationStack.push('/test2'); + + vm.forward(); + + expect(vm.visitPath).toHaveBeenCalledWith('/test'); + }); + }); + + describe('refresh', () => { + it('calls refresh with current path', () => { + spyOn(vm, 'visitPath'); + + vm.path = '/test'; + + vm.refresh(); + + expect(vm.visitPath).toHaveBeenCalledWith('/test'); + }); + }); + + describe('visitPath', () => { + it('updates iframe src with passed in path', () => { + vm.visitPath('/testpath'); + + expect(manager.iframe.src).toBe(`${gl.TEST_HOST}/testpath`); + }); + }); +}); diff --git a/spec/javascripts/ide/components/shared/tokened_input_spec.js b/spec/javascripts/ide/components/shared/tokened_input_spec.js new file mode 100644 index 00000000000..09940fe8c6a --- /dev/null +++ b/spec/javascripts/ide/components/shared/tokened_input_spec.js @@ -0,0 +1,132 @@ +import Vue from 'vue'; +import TokenedInput from '~/ide/components/shared/tokened_input.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const TEST_PLACEHOLDER = 'Searching in test'; +const TEST_TOKENS = [ + { label: 'lorem', id: 1 }, + { label: 'ipsum', id: 2 }, + { label: 'dolar', id: 3 }, +]; +const TEST_VALUE = 'lorem'; + +function getTokenElements(vm) { + return Array.from(vm.$el.querySelectorAll('.filtered-search-token button')); +} + +function createBackspaceEvent() { + const e = new Event('keyup'); + e.keyCode = 8; + e.which = e.keyCode; + e.altKey = false; + e.ctrlKey = true; + e.shiftKey = false; + e.metaKey = false; + return e; +} + +describe('IDE shared/TokenedInput', () => { + const Component = Vue.extend(TokenedInput); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + tokens: TEST_TOKENS, + placeholder: TEST_PLACEHOLDER, + value: TEST_VALUE, + }); + + spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders tokens', () => { + const renderedTokens = getTokenElements(vm) + .map(x => x.textContent.trim()); + + expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label)); + }); + + it('renders input', () => { + expect(vm.$refs.input).toBeTruthy(); + expect(vm.$refs.input).toHaveValue(TEST_VALUE); + }); + + it('renders placeholder, when tokens are empty', done => { + vm.tokens = []; + + vm.$nextTick() + .then(() => { + expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER); + }) + .then(done) + .catch(done.fail); + }); + + it('triggers "removeToken" on token click', () => { + getTokenElements(vm)[0].click(); + + expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]); + }); + + it('when input triggers backspace event, it calls "onBackspace"', () => { + spyOn(vm, 'onBackspace'); + + vm.$refs.input.dispatchEvent(createBackspaceEvent()); + vm.$refs.input.dispatchEvent(createBackspaceEvent()); + + expect(vm.onBackspace).toHaveBeenCalledTimes(2); + }); + + it('triggers "removeToken" on backspaces when value is empty', () => { + vm.value = ''; + + vm.onBackspace(); + expect(vm.$emit).not.toHaveBeenCalled(); + expect(vm.backspaceCount).toEqual(1); + + vm.onBackspace(); + expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]); + expect(vm.backspaceCount).toEqual(0); + }); + + it('does not trigger "removeToken" on backspaces when value is not empty', () => { + vm.onBackspace(); + vm.onBackspace(); + + expect(vm.backspaceCount).toEqual(0); + expect(vm.$emit).not.toHaveBeenCalled(); + }); + + it('does not trigger "removeToken" on backspaces when tokens are empty', () => { + vm.tokens = []; + + vm.onBackspace(); + vm.onBackspace(); + + expect(vm.backspaceCount).toEqual(0); + expect(vm.$emit).not.toHaveBeenCalled(); + }); + + it('triggers "focus" on input focus', () => { + vm.$refs.input.dispatchEvent(new Event('focus')); + + expect(vm.$emit).toHaveBeenCalledWith('focus'); + }); + + it('triggers "blur" on input blur', () => { + vm.$refs.input.dispatchEvent(new Event('blur')); + + expect(vm.$emit).toHaveBeenCalledWith('blur'); + }); + + it('triggers "input" with value on input change', () => { + vm.$refs.input.value = 'something-else'; + vm.$refs.input.dispatchEvent(new Event('input')); + + expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else'); + }); +}); diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js index 569fa5c7aae..c11c482fef8 100644 --- a/spec/javascripts/ide/helpers.js +++ b/spec/javascripts/ide/helpers.js @@ -4,6 +4,7 @@ import state from '~/ide/stores/state'; import commitState from '~/ide/stores/modules/commit/state'; import mergeRequestsState from '~/ide/stores/modules/merge_requests/state'; import pipelinesState from '~/ide/stores/modules/pipelines/state'; +import branchesState from '~/ide/stores/modules/branches/state'; export const resetStore = store => { const newState = { @@ -11,6 +12,7 @@ export const resetStore = store => { commit: commitState(), mergeRequests: mergeRequestsState(), pipelines: pipelinesState(), + branches: branchesState(), }; store.replaceState(newState); }; diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js index 7be450a0df7..4fe826943b2 100644 --- a/spec/javascripts/ide/mock_data.js +++ b/spec/javascripts/ide/mock_data.js @@ -165,3 +165,33 @@ export const mergeRequests = [ web_url: `${gl.TEST_HOST}/namespace/project-path/merge_requests/1`, }, ]; + +export const branches = [ + { + id: 1, + name: 'master', + commit: { + message: 'Update master branch', + committed_date: '2018-08-01T00:20:05Z', + }, + can_push: true, + }, + { + id: 2, + name: 'feature/lorem-ipsum', + commit: { + message: 'Update some stuff', + committed_date: '2018-08-02T00:00:05Z', + }, + can_push: true, + }, + { + id: 3, + name: 'feature/dolar-amit', + commit: { + message: 'Update some more stuff', + committed_date: '2018-06-30T00:20:05Z', + }, + can_push: true, + }, +]; diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index 70883e16b0d..9c135661997 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -179,4 +179,14 @@ describe('IDE store getters', () => { }); }); }); + + describe('packageJson', () => { + it('returns package.json entry', () => { + localState.entries['package.json'] = { name: 'package.json' }; + + expect(getters.packageJson(localState)).toEqual({ + name: 'package.json', + }); + }); + }); }); diff --git a/spec/javascripts/ide/stores/modules/branches/actions_spec.js b/spec/javascripts/ide/stores/modules/branches/actions_spec.js new file mode 100644 index 00000000000..a0fce578958 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/branches/actions_spec.js @@ -0,0 +1,193 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import state from '~/ide/stores/modules/branches/state'; +import * as types from '~/ide/stores/modules/branches/mutation_types'; +import testAction from 'spec/helpers/vuex_action_helper'; +import { + requestBranches, + receiveBranchesError, + receiveBranchesSuccess, + fetchBranches, + resetBranches, + openBranch, +} from '~/ide/stores/modules/branches/actions'; +import { branches, projectData } from '../../../mock_data'; + +describe('IDE branches actions', () => { + const TEST_SEARCH = 'foosearch'; + let mockedContext; + let mockedState; + let mock; + + beforeEach(() => { + mockedContext = { + dispatch() {}, + rootState: { + currentProjectId: projectData.name_with_namespace, + }, + rootGetters: { + currentProject: projectData, + }, + state: state(), + }; + + // testAction looks for rootGetters in state, + // so they need to be concatenated here. + mockedState = { + ...mockedContext.state, + ...mockedContext.rootGetters, + ...mockedContext.rootState, + }; + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('requestBranches', () => { + it('should commit request', done => { + testAction( + requestBranches, + null, + mockedContext.state, + [{ type: types.REQUEST_BRANCHES }], + [], + done, + ); + }); + }); + + describe('receiveBranchesError', () => { + it('should should commit error', done => { + + testAction( + receiveBranchesError, + { search: TEST_SEARCH }, + mockedContext.state, + [{ type: types.RECEIVE_BRANCHES_ERROR }], + [ + { + type: 'setErrorMessage', + payload: { + text: 'Error loading branches.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { search: TEST_SEARCH }, + }, + }, + ], + done, + ); + }); + }); + + describe('receiveBranchesSuccess', () => { + it('should commit received data', done => { + testAction( + receiveBranchesSuccess, + branches, + mockedContext.state, + [{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branches }], + [], + done, + ); + }); + }); + + describe('fetchBranches', () => { + beforeEach(() => { + gon.api_version = 'v4'; + }); + + describe('success', () => { + beforeEach(() => { + mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(200, branches); + }); + + it('calls API with params', () => { + const apiSpy = spyOn(axios, 'get').and.callThrough(); + + fetchBranches(mockedContext, { search: TEST_SEARCH }); + + expect(apiSpy).toHaveBeenCalledWith(jasmine.anything(), { + params: jasmine.objectContaining({ + search: TEST_SEARCH, + sort: 'updated_desc', + }), + }); + }); + + it('dispatches success with received data', done => { + testAction( + fetchBranches, + { search: TEST_SEARCH }, + mockedState, + [], + [ + { type: 'requestBranches' }, + { type: 'resetBranches' }, + { + type: 'receiveBranchesSuccess', + payload: branches, + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(500); + }); + + it('dispatches error', done => { + testAction( + fetchBranches, + { search: TEST_SEARCH }, + mockedState, + [], + [ + { type: 'requestBranches' }, + { type: 'resetBranches' }, + { + type: 'receiveBranchesError', + payload: { search: TEST_SEARCH }, + }, + ], + done, + ); + }); + }); + + describe('resetBranches', () => { + it('commits reset', done => { + testAction( + resetBranches, + null, + mockedContext.state, + [{ type: types.RESET_BRANCHES }], + [], + done, + ); + }); + }); + + describe('openBranch', () => { + it('dispatches goToRoute action with path', done => { + const branchId = branches[0].name; + const expectedPath = `/project/${projectData.name_with_namespace}/edit/${branchId}`; + testAction( + openBranch, + branchId, + mockedState, + [], + [{ type: 'goToRoute', payload: expectedPath }], + done, + ); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/branches/mutations_spec.js b/spec/javascripts/ide/stores/modules/branches/mutations_spec.js new file mode 100644 index 00000000000..be91440f119 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/branches/mutations_spec.js @@ -0,0 +1,51 @@ +import state from '~/ide/stores/modules/branches/state'; +import mutations from '~/ide/stores/modules/branches/mutations'; +import * as types from '~/ide/stores/modules/branches/mutation_types'; +import { branches } from '../../../mock_data'; + +describe('IDE branches mutations', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe(types.REQUEST_BRANCHES, () => { + it('sets loading to true', () => { + mutations[types.REQUEST_BRANCHES](mockedState); + + expect(mockedState.isLoading).toBe(true); + }); + }); + + describe(types.RECEIVE_BRANCHES_ERROR, () => { + it('sets loading to false', () => { + mutations[types.RECEIVE_BRANCHES_ERROR](mockedState); + + expect(mockedState.isLoading).toBe(false); + }); + }); + + describe(types.RECEIVE_BRANCHES_SUCCESS, () => { + it('sets branches', () => { + const expectedBranches = branches.map(branch => ({ + name: branch.name, + committedDate: branch.commit.committed_date, + })); + + mutations[types.RECEIVE_BRANCHES_SUCCESS](mockedState, branches); + + expect(mockedState.branches).toEqual(expectedBranches); + }); + }); + + describe(types.RESET_BRANCHES, () => { + it('clears branches array', () => { + mockedState.branches = ['test']; + + mutations[types.RESET_BRANCHES](mockedState); + + expect(mockedState.branches).toEqual([]); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js b/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js index d063f1ea860..62699143a91 100644 --- a/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js @@ -8,9 +8,7 @@ import { receiveMergeRequestsSuccess, fetchMergeRequests, resetMergeRequests, - openMergeRequest, } from '~/ide/stores/modules/merge_requests/actions'; -import router from '~/ide/ide_router'; import { mergeRequests } from '../../../mock_data'; import testAction from '../../../../helpers/vuex_action_helper'; @@ -28,12 +26,12 @@ describe('IDE merge requests actions', () => { }); describe('requestMergeRequests', () => { - it('should should commit request', done => { + it('should commit request', done => { testAction( requestMergeRequests, - 'created', + null, mockedState, - [{ type: types.REQUEST_MERGE_REQUESTS, payload: 'created' }], + [{ type: types.REQUEST_MERGE_REQUESTS }], [], done, ); @@ -46,7 +44,7 @@ describe('IDE merge requests actions', () => { receiveMergeRequestsError, { type: 'created', search: '' }, mockedState, - [{ type: types.RECEIVE_MERGE_REQUESTS_ERROR, payload: 'created' }], + [{ type: types.RECEIVE_MERGE_REQUESTS_ERROR }], [ { type: 'setErrorMessage', @@ -67,12 +65,12 @@ describe('IDE merge requests actions', () => { it('should commit received data', done => { testAction( receiveMergeRequestsSuccess, - { type: 'created', data: 'data' }, + mergeRequests, mockedState, [ { type: types.RECEIVE_MERGE_REQUESTS_SUCCESS, - payload: { type: 'created', data: 'data' }, + payload: mergeRequests, }, ], [], @@ -129,11 +127,11 @@ describe('IDE merge requests actions', () => { mockedState, [], [ - { type: 'requestMergeRequests', payload: 'created' }, - { type: 'resetMergeRequests', payload: 'created' }, + { type: 'requestMergeRequests' }, + { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsSuccess', - payload: { type: 'created', data: mergeRequests }, + payload: mergeRequests, }, ], done, @@ -149,12 +147,12 @@ describe('IDE merge requests actions', () => { it('dispatches error', done => { testAction( fetchMergeRequests, - { type: 'created' }, + { type: 'created', search: '' }, mockedState, [], [ - { type: 'requestMergeRequests', payload: 'created' }, - { type: 'resetMergeRequests', payload: 'created' }, + { type: 'requestMergeRequests' }, + { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } }, ], done, @@ -167,59 +165,12 @@ describe('IDE merge requests actions', () => { it('commits reset', done => { testAction( resetMergeRequests, - 'created', + null, mockedState, - [{ type: types.RESET_MERGE_REQUESTS, payload: 'created' }], + [{ type: types.RESET_MERGE_REQUESTS }], [], done, ); }); }); - - describe('openMergeRequest', () => { - beforeEach(() => { - spyOn(router, 'push'); - }); - - it('commits reset mutations and actions', done => { - const commit = jasmine.createSpy(); - const dispatch = jasmine.createSpy().and.returnValue(Promise.resolve()); - openMergeRequest({ commit, dispatch }, { projectPath: 'gitlab-org/gitlab-ce', id: '1' }); - - setTimeout(() => { - expect(commit.calls.argsFor(0)).toEqual(['CLEAR_PROJECTS', null, { root: true }]); - expect(commit.calls.argsFor(1)).toEqual(['SET_CURRENT_MERGE_REQUEST', '1', { root: true }]); - expect(commit.calls.argsFor(2)).toEqual(['RESET_OPEN_FILES', null, { root: true }]); - - expect(dispatch.calls.argsFor(0)).toEqual(['setCurrentBranchId', '', { root: true }]); - expect(dispatch.calls.argsFor(1)).toEqual([ - 'pipelines/stopPipelinePolling', - null, - { root: true }, - ]); - expect(dispatch.calls.argsFor(2)).toEqual(['setRightPane', null, { root: true }]); - expect(dispatch.calls.argsFor(3)).toEqual([ - 'pipelines/resetLatestPipeline', - null, - { root: true }, - ]); - expect(dispatch.calls.argsFor(4)).toEqual([ - 'pipelines/clearEtagPoll', - null, - { root: true }, - ]); - - done(); - }); - }); - - it('pushes new route', () => { - openMergeRequest( - { commit() {}, dispatch: () => Promise.resolve() }, - { projectPath: 'gitlab-org/gitlab-ce', id: '1' }, - ); - - expect(router.push).toHaveBeenCalledWith('/project/gitlab-org/gitlab-ce/merge_requests/1'); - }); - }); }); diff --git a/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js b/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js index ea03131d90d..664d3914564 100644 --- a/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js +++ b/spec/javascripts/ide/stores/modules/merge_requests/mutations_spec.js @@ -12,29 +12,26 @@ describe('IDE merge requests mutations', () => { describe(types.REQUEST_MERGE_REQUESTS, () => { it('sets loading to true', () => { - mutations[types.REQUEST_MERGE_REQUESTS](mockedState, 'created'); + mutations[types.REQUEST_MERGE_REQUESTS](mockedState); - expect(mockedState.created.isLoading).toBe(true); + expect(mockedState.isLoading).toBe(true); }); }); describe(types.RECEIVE_MERGE_REQUESTS_ERROR, () => { it('sets loading to false', () => { - mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](mockedState, 'created'); + mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](mockedState); - expect(mockedState.created.isLoading).toBe(false); + expect(mockedState.isLoading).toBe(false); }); }); describe(types.RECEIVE_MERGE_REQUESTS_SUCCESS, () => { it('sets merge requests', () => { gon.gitlab_url = gl.TEST_HOST; - mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, { - type: 'created', - data: mergeRequests, - }); + mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, mergeRequests); - expect(mockedState.created.mergeRequests).toEqual([ + expect(mockedState.mergeRequests).toEqual([ { id: 1, iid: 1, @@ -50,9 +47,9 @@ describe('IDE merge requests mutations', () => { it('clears merge request array', () => { mockedState.mergeRequests = ['test']; - mutations[types.RESET_MERGE_REQUESTS](mockedState, 'created'); + mutations[types.RESET_MERGE_REQUESTS](mockedState); - expect(mockedState.created.mergeRequests).toEqual([]); + expect(mockedState.mergeRequests).toEqual([]); }); }); }); diff --git a/spec/javascripts/notes/components/discussion_counter_spec.js b/spec/javascripts/notes/components/discussion_counter_spec.js index a3869cc6498..d09bc5037ef 100644 --- a/spec/javascripts/notes/components/discussion_counter_spec.js +++ b/spec/javascripts/notes/components/discussion_counter_spec.js @@ -46,7 +46,7 @@ describe('DiscussionCounter component', () => { discussions, }); setFixtures(` - <div data-discussion-id="${firstDiscussionId}"></div> + <div class="discussion" data-discussion-id="${firstDiscussionId}"></div> `); vm.jumpToFirstUnresolvedDiscussion(); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index 7da931fd9cb..2a01bd85520 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -14,6 +14,7 @@ describe('noteable_discussion component', () => { preloadFixtures(discussionWithTwoUnresolvedNotes); beforeEach(() => { + window.mrTabs = {}; store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); @@ -46,10 +47,15 @@ describe('noteable_discussion component', () => { it('should toggle reply form', done => { vm.$el.querySelector('.js-vue-discussion-reply').click(); + Vue.nextTick(() => { - expect(vm.$refs.noteForm).not.toBeNull(); expect(vm.isReplying).toEqual(true); - done(); + + // There is a watcher for `isReplying` which will init autosave in the next tick + Vue.nextTick(() => { + expect(vm.$refs.noteForm).not.toBeNull(); + done(); + }); }); }); @@ -101,33 +107,29 @@ describe('noteable_discussion component', () => { describe('methods', () => { describe('jumpToNextDiscussion', () => { - it('expands next unresolved discussion', () => { - spyOn(vm, 'expandDiscussion').and.stub(); - const discussions = [ - discussionMock, - { - ...discussionMock, - id: discussionMock.id + 1, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], - }, - { - ...discussionMock, - id: discussionMock.id + 2, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }], - }, - ]; - const nextDiscussionId = discussionMock.id + 2; - store.replaceState({ - ...store.state, - discussions, - }); - setFixtures(` - <div data-discussion-id="${nextDiscussionId}"></div> - `); + it('expands next unresolved discussion', done => { + const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; + discussion2.resolved = false; + discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to) + vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]); + window.mrTabs.currentAction = 'show'; + + Vue.nextTick() + .then(() => { + spyOn(vm, 'expandDiscussion').and.stub(); + + const nextDiscussionId = discussion2.id; + + setFixtures(` + <div class="discussion" data-discussion-id="${nextDiscussionId}"></div> + `); - vm.jumpToNextDiscussion(); + vm.jumpToNextDiscussion(); - expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId }); + expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId }); + }) + .then(done) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index be2a8ba67fe..67f6a9629d9 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -1168,3 +1168,87 @@ export const collapsedSystemNotes = [ diff_discussion: false, }, ]; + +export const discussion1 = { + id: 'abc1', + resolvable: true, + resolved: false, + diff_file: { + file_path: 'about.md', + }, + position: { + formatter: { + new_line: 50, + old_line: null, + }, + }, + notes: [ + { + created_at: '2018-07-04T16:25:41.749Z', + }, + ], +}; + +export const resolvedDiscussion1 = { + id: 'abc1', + resolvable: true, + resolved: true, + diff_file: { + file_path: 'about.md', + }, + position: { + formatter: { + new_line: 50, + old_line: null, + }, + }, + notes: [ + { + created_at: '2018-07-04T16:25:41.749Z', + }, + ], +}; + +export const discussion2 = { + id: 'abc2', + resolvable: true, + resolved: false, + diff_file: { + file_path: 'README.md', + }, + position: { + formatter: { + new_line: null, + old_line: 20, + }, + }, + notes: [ + { + created_at: '2018-07-04T12:05:41.749Z', + }, + ], +}; + +export const discussion3 = { + id: 'abc3', + resolvable: true, + resolved: false, + diff_file: { + file_path: 'README.md', + }, + position: { + formatter: { + new_line: 21, + old_line: null, + }, + }, + notes: [ + { + created_at: '2018-07-05T17:25:41.749Z', + }, + ], +}; + +export const unresolvableDiscussion = { + resolvable: false, +}; diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index 41599e00122..7f8ede51508 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -5,6 +5,11 @@ import { noteableDataMock, individualNote, collapseNotesMock, + discussion1, + discussion2, + discussion3, + resolvedDiscussion1, + unresolvableDiscussion, } from '../mock_data'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; @@ -109,4 +114,154 @@ describe('Getters Notes Store', () => { expect(getters.isNotesFetched(state)).toBeFalsy(); }); }); + + describe('allResolvableDiscussions', () => { + it('should return only resolvable discussions in same order', () => { + const localGetters = { + allDiscussions: [ + discussion3, + unresolvableDiscussion, + discussion1, + unresolvableDiscussion, + discussion2, + ], + }; + + expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([ + discussion3, + discussion1, + discussion2, + ]); + }); + + it('should return empty array if there are no resolvable discussions', () => { + const localGetters = { + allDiscussions: [unresolvableDiscussion, unresolvableDiscussion], + }; + + expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([]); + }); + }); + + describe('unresolvedDiscussionsIdsByDiff', () => { + it('should return all discussions IDs in diff order', () => { + const localGetters = { + allResolvableDiscussions: [discussion3, discussion1, discussion2], + }; + + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([ + 'abc1', + 'abc2', + 'abc3', + ]); + }); + + it('should return empty array if all discussions have been resolved', () => { + const localGetters = { + allResolvableDiscussions: [resolvedDiscussion1], + }; + + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]); + }); + }); + + describe('unresolvedDiscussionsIdsByDate', () => { + it('should return all discussions in date ascending order', () => { + const localGetters = { + allResolvableDiscussions: [discussion3, discussion1, discussion2], + }; + + expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([ + 'abc2', + 'abc1', + 'abc3', + ]); + }); + + it('should return empty array if all discussions have been resolved', () => { + const localGetters = { + allResolvableDiscussions: [resolvedDiscussion1], + }; + + expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([]); + }); + }); + + describe('unresolvedDiscussionsIdsOrdered', () => { + const localGetters = { + unresolvedDiscussionsIdsByDate: ['123', '456'], + unresolvedDiscussionsIdsByDiff: ['abc', 'def'], + }; + + it('should return IDs ordered by diff when diffOrder param is true', () => { + expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(true)).toEqual([ + 'abc', + 'def', + ]); + }); + + it('should return IDs ordered by date when diffOrder param is not true', () => { + expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(false)).toEqual([ + '123', + '456', + ]); + expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(undefined)).toEqual([ + '123', + '456', + ]); + }); + }); + + describe('isLastUnresolvedDiscussion', () => { + const localGetters = { + unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'], + }; + + it('should return true if the discussion id provided is the last', () => { + expect(getters.isLastUnresolvedDiscussion(state, localGetters)('789')).toBe(true); + }); + + it('should return false if the discussion id provided is not the last', () => { + expect(getters.isLastUnresolvedDiscussion(state, localGetters)('123')).toBe(false); + expect(getters.isLastUnresolvedDiscussion(state, localGetters)('456')).toBe(false); + }); + }); + + describe('nextUnresolvedDiscussionId', () => { + const localGetters = { + unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'], + }; + + it('should return the ID of the discussion after the ID provided', () => { + expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456'); + expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789'); + expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe(undefined); + }); + }); + + describe('firstUnresolvedDiscussionId', () => { + const localGetters = { + unresolvedDiscussionsIdsByDate: ['123', '456'], + unresolvedDiscussionsIdsByDiff: ['abc', 'def'], + }; + + it('should return the first discussion id by diff when diffOrder param is true', () => { + expect(getters.firstUnresolvedDiscussionId(state, localGetters)(true)).toBe('abc'); + }); + + it('should return the first discussion id by date when diffOrder param is not true', () => { + expect(getters.firstUnresolvedDiscussionId(state, localGetters)(false)).toBe('123'); + expect(getters.firstUnresolvedDiscussionId(state, localGetters)(undefined)).toBe('123'); + }); + + it('should be falsy if all discussions are resolved', () => { + const localGettersFalsy = { + unresolvedDiscussionsIdsByDiff: [], + unresolvedDiscussionsIdsByDate: [], + }; + + expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(true)).toBeFalsy(); + expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy(); + }); + }); }); diff --git a/spec/javascripts/pages/profiles/show/emoji_menu_spec.js b/spec/javascripts/pages/profiles/show/emoji_menu_spec.js new file mode 100644 index 00000000000..b70368fc92f --- /dev/null +++ b/spec/javascripts/pages/profiles/show/emoji_menu_spec.js @@ -0,0 +1,117 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import EmojiMenu from '~/pages/profiles/show/emoji_menu'; +import { TEST_HOST } from 'spec/test_constants'; + +describe('EmojiMenu', () => { + const dummyEmojiTag = '<dummy></tag>'; + const dummyToggleButtonSelector = '.toggle-button-selector'; + const dummyMenuClass = 'dummy-menu-class'; + + let emojiMenu; + let dummySelectEmojiCallback; + let dummyEmojiList; + + beforeEach(() => { + dummySelectEmojiCallback = jasmine.createSpy('dummySelectEmojiCallback'); + dummyEmojiList = { + glEmojiTag() { + return dummyEmojiTag; + }, + normalizeEmojiName(emoji) { + return emoji; + }, + isEmojiNameValid() { + return true; + }, + getEmojiCategoryMap() { + return { dummyCategory: [] }; + }, + }; + + emojiMenu = new EmojiMenu( + dummyEmojiList, + dummyToggleButtonSelector, + dummyMenuClass, + dummySelectEmojiCallback, + ); + }); + + afterEach(() => { + emojiMenu.destroy(); + }); + + describe('addAward', () => { + const dummyAwardUrl = `${TEST_HOST}/award/url`; + const dummyEmoji = 'tropical_fish'; + const dummyVotesBlock = () => $('<div />'); + + it('calls selectEmojiCallback', done => { + expect(dummySelectEmojiCallback).not.toHaveBeenCalled(); + + emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { + expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag); + done(); + }); + }); + + it('does not make an axios requst', done => { + spyOn(axios, 'request').and.stub(); + + emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { + expect(axios.request).not.toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('bindEvents', () => { + beforeEach(() => { + spyOn(emojiMenu, 'registerEventListener').and.stub(); + }); + + it('binds event listeners to custom toggle button', () => { + emojiMenu.bindEvents(); + + expect(emojiMenu.registerEventListener).toHaveBeenCalledWith( + 'one', + jasmine.anything(), + 'mouseenter focus', + dummyToggleButtonSelector, + 'mouseenter focus', + jasmine.anything(), + ); + expect(emojiMenu.registerEventListener).toHaveBeenCalledWith( + 'on', + jasmine.anything(), + 'click', + dummyToggleButtonSelector, + jasmine.anything(), + ); + }); + + it('binds event listeners to custom menu class', () => { + emojiMenu.bindEvents(); + + expect(emojiMenu.registerEventListener).toHaveBeenCalledWith( + 'on', + jasmine.anything(), + 'click', + `.js-awards-block .js-emoji-btn, .${dummyMenuClass} .js-emoji-btn`, + jasmine.anything(), + ); + }); + }); + + describe('createEmojiMenu', () => { + it('renders the menu with custom menu class', () => { + const menuElement = () => + document.body.querySelector(`.emoji-menu.${dummyMenuClass} .emoji-menu-content`); + expect(menuElement()).toBe(null); + + emojiMenu.createEmojiMenu(); + + expect(menuElement()).not.toBe(null); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index 4a4f2259d23..ddd580ae8b7 100644 --- a/spec/javascripts/pipelines/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js @@ -35,7 +35,9 @@ describe('Pipeline Url Component', () => { }, }).$mount(); - expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo'); + expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual( + 'foo', + ); expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1'); }); @@ -61,11 +63,11 @@ describe('Pipeline Url Component', () => { const image = component.$el.querySelector('.js-pipeline-url-user img'); - expect( - component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'), - ).toEqual(mockData.pipeline.user.web_url); + expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual( + mockData.pipeline.user.web_url, + ); expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name); - expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url); + expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`); }); it('should render "API" when no user is provided', () => { @@ -100,7 +102,9 @@ describe('Pipeline Url Component', () => { }).$mount(); expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest'); - expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid'); + expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain( + 'yaml invalid', + ); expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck'); }); @@ -121,9 +125,9 @@ describe('Pipeline Url Component', () => { }, }).$mount(); - expect( - component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim(), - ).toEqual('Auto DevOps'); + expect(component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim()).toEqual( + 'Auto DevOps', + ); }); it('should render error badge when pipeline has a failure reason set', () => { @@ -142,6 +146,8 @@ describe('Pipeline Url Component', () => { }).$mount(); expect(component.$el.querySelector('.js-pipeline-url-failure').textContent).toContain('error'); - expect(component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title')).toContain('some reason'); + expect( + component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title'), + ).toContain('some reason'); }); }); diff --git a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js index d86e565036c..333cefe5f8a 100644 --- a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js +++ b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js @@ -7,6 +7,7 @@ import mountComponent from '../../helpers/vue_mount_component_helper'; import newFailedTestReports from '../mock_data/new_failures_report.json'; import successTestReports from '../mock_data/no_failures_report.json'; import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json'; +import resolvedFailures from '../mock_data/resolved_failures.json'; describe('Grouped Test Reports App', () => { let vm; @@ -123,6 +124,41 @@ describe('Grouped Test Reports App', () => { }); }); + describe('with resolved failures', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, resolvedFailures, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders summary text', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.fa-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 2 fixed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found 2 fixed test results out of 8 total tests', + ); + done(); + }, 0); + }); + + it('renders resolved failures', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain( + resolvedFailures.suites[0].resolved_failures[0].name, + ); + expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain( + resolvedFailures.suites[0].resolved_failures[1].name, + ); + done(); + }, 0); + }); + }); + describe('with error', () => { beforeEach(() => { mock.onGet('test_results.json').reply(500, {}, {}); diff --git a/spec/javascripts/reports/mock_data/resolved_failures.json b/spec/javascripts/reports/mock_data/resolved_failures.json new file mode 100644 index 00000000000..d1f347ce5e6 --- /dev/null +++ b/spec/javascripts/reports/mock_data/resolved_failures.json @@ -0,0 +1,37 @@ +{ + "status": "success", + "summary": { "total": 11, "resolved": 2, "failed": 0 }, + "suites": [ + { + "name": "rspec:pg", + "status": "success", + "summary": { "total": 8, "resolved": 2, "failed": 0 }, + "new_failures": [], + "resolved_failures": [ + { + "status": "success", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.000411, + "system_output": null, + "stack_trace": null + }, + { + "status": "success", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 7.6e-5, + "system_output": null, + "stack_trace": null + } + ], + "existing_failures": [] + }, + { + "name": "java ant", + "status": "success", + "summary": { "total": 3, "resolved": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [] + } + ] +} diff --git a/spec/javascripts/reports/store/mutations_spec.js b/spec/javascripts/reports/store/mutations_spec.js index 8f99d2675a5..7d19b16efb9 100644 --- a/spec/javascripts/reports/store/mutations_spec.js +++ b/spec/javascripts/reports/store/mutations_spec.js @@ -72,6 +72,10 @@ describe('Reports Store Mutations', () => { expect(stateCopy.isLoading).toEqual(false); }); + it('should reset hasError', () => { + expect(stateCopy.hasError).toEqual(false); + }); + it('should set summary counts', () => { expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total); expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved); diff --git a/spec/javascripts/sidebar/todo_spec.js b/spec/javascripts/sidebar/todo_spec.js new file mode 100644 index 00000000000..a929b804a29 --- /dev/null +++ b/spec/javascripts/sidebar/todo_spec.js @@ -0,0 +1,158 @@ +import Vue from 'vue'; + +import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const createComponent = ({ + issuableId = 1, + issuableType = 'epic', + isTodo, + isActionActive, + collapsed, +}) => { + const Component = Vue.extend(SidebarTodos); + + return mountComponent(Component, { + issuableId, + issuableType, + isTodo, + isActionActive, + collapsed, + }); +}; + +describe('SidebarTodo', () => { + let vm; + + beforeEach(() => { + vm = createComponent({}); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('buttonClasses', () => { + it('returns todo button classes for when `collapsed` prop is `false`', () => { + expect(vm.buttonClasses).toBe('btn btn-default btn-todo issuable-header-btn float-right'); + }); + + it('returns todo button classes for when `collapsed` prop is `true`', done => { + vm.collapsed = true; + Vue.nextTick() + .then(() => { + expect(vm.buttonClasses).toBe('btn-blank btn-todo sidebar-collapsed-icon dont-change-state'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('buttonLabel', () => { + it('returns todo button text for marking todo as done when `isTodo` prop is `true`', () => { + expect(vm.buttonLabel).toBe('Mark todo as done'); + }); + + it('returns todo button text for add todo when `isTodo` prop is `false`', done => { + vm.isTodo = false; + Vue.nextTick() + .then(() => { + expect(vm.buttonLabel).toBe('Add todo'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('collapsedButtonIconClasses', () => { + it('returns collapsed button icon class when `isTodo` prop is `true`', () => { + expect(vm.collapsedButtonIconClasses).toBe('todo-undone'); + }); + + it('returns empty string when `isTodo` prop is `false`', done => { + vm.isTodo = false; + Vue.nextTick() + .then(() => { + expect(vm.collapsedButtonIconClasses).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('collapsedButtonIcon', () => { + it('returns button icon name when `isTodo` prop is `true`', () => { + expect(vm.collapsedButtonIcon).toBe('todo-done'); + }); + + it('returns button icon name when `isTodo` prop is `false`', done => { + vm.isTodo = false; + Vue.nextTick() + .then(() => { + expect(vm.collapsedButtonIcon).toBe('todo-add'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('methods', () => { + describe('handleButtonClick', () => { + it('emits `toggleTodo` event on component', () => { + spyOn(vm, '$emit'); + vm.handleButtonClick(); + expect(vm.$emit).toHaveBeenCalledWith('toggleTodo'); + }); + }); + }); + + describe('template', () => { + it('renders component container element', () => { + const dataAttributes = { + issuableId: '1', + issuableType: 'epic', + originalTitle: 'Mark todo as done', + placement: 'left', + container: 'body', + boundary: 'viewport', + }; + expect(vm.$el.nodeName).toBe('BUTTON'); + + const elDataAttrs = vm.$el.dataset; + Object.keys(elDataAttrs).forEach((attr) => { + expect(elDataAttrs[attr]).toBe(dataAttributes[attr]); + }); + }); + + it('renders button label element when `collapsed` prop is `false`', () => { + const buttonLabelEl = vm.$el.querySelector('span.issuable-todo-inner'); + expect(buttonLabelEl).not.toBeNull(); + expect(buttonLabelEl.innerText.trim()).toBe('Mark todo as done'); + }); + + it('renders button icon when `collapsed` prop is `true`', done => { + vm.collapsed = true; + Vue.nextTick() + .then(() => { + const buttonIconEl = vm.$el.querySelector('svg'); + expect(buttonIconEl).not.toBeNull(); + expect(buttonIconEl.querySelector('use').getAttribute('xlink:href')).toContain('todo-done'); + }) + .then(done) + .catch(done.fail); + }); + + it('renders loading icon when `isActionActive` prop is true', done => { + vm.isActionActive = true; + Vue.nextTick() + .then(() => { + const loadingEl = vm.$el.querySelector('span.loading-container'); + expect(loadingEl).not.toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 5f4f4c26d74..4452c470b82 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -95,7 +95,7 @@ beforeEach(() => { let longRunningTestTimeoutHandle; -beforeEach((done) => { +beforeEach(done => { longRunningTestTimeoutHandle = setTimeout(() => { done.fail('Test is running too long!'); }, 2000); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js index b878286ae3f..dde49b4a5d7 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -170,8 +170,6 @@ describe('ImageDiffViewer', () => { vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); vm.$nextTick(() => { - expect(vm.$el.querySelector('.dragger').style.left).toBe('100px'); - dragSlider(vm.$el.querySelector('.dragger')); vm.$nextTick(() => { diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js index ba897f4660d..2796cd088c6 100644 --- a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js +++ b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js @@ -2,15 +2,15 @@ import Vue from 'vue'; import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; const defaultLabel = 'Select'; const customLabel = 'Select project'; -const createComponent = config => { +const createComponent = (props, slots = {}) => { const Component = Vue.extend(dropdownButtonComponent); - return mountComponent(Component, config); + return mountComponentWithSlots(Component, { props, slots }); }; describe('DropdownButtonComponent', () => { @@ -65,5 +65,14 @@ describe('DropdownButtonComponent', () => { expect(dropdownIconEl).not.toBeNull(); expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); }); + + it('renders slot, if default slot exists', () => { + vm = createComponent({}, { + default: ['Lorem Ipsum Dolar'], + }); + + expect(vm.$el).not.toContainElement('.dropdown-toggle-text'); + expect(vm.$el).toHaveText('Lorem Ipsum Dolar'); + }); }); }); diff --git a/spec/javascripts/vue_shared/components/icon_spec.js b/spec/javascripts/vue_shared/components/icon_spec.js index 882420e602e..01f4649339e 100644 --- a/spec/javascripts/vue_shared/components/icon_spec.js +++ b/spec/javascripts/vue_shared/components/icon_spec.js @@ -13,6 +13,7 @@ describe('Sprite Icon Component', function () { name: 'commit', size: 32, cssClasses: 'extraclasses', + tabIndex: '0', }); }); @@ -58,5 +59,9 @@ describe('Sprite Icon Component', function () { it('`name` validator should return false for existing icons', () => { expect(Icon.props.name.validator('commit')).toBe(true); }); + + it('should contain `tabindex` attribute on svg element when `tabIndex` prop is defined', () => { + expect(icon.$el.getAttribute('tabindex')).toBe('0'); + }); }); }); diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js b/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js index 7e57c51bf29..db665fdaad3 100644 --- a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js @@ -27,7 +27,7 @@ describe('issue placeholder system note component', () => { userDataMock.path, ); expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual( - userDataMock.avatar_url, + `${userDataMock.avatar_url}?width=40`, ); }); }); diff --git a/spec/javascripts/vue_shared/components/project_avatar/default_spec.js b/spec/javascripts/vue_shared/components/project_avatar/default_spec.js new file mode 100644 index 00000000000..5fed3f4b892 --- /dev/null +++ b/spec/javascripts/vue_shared/components/project_avatar/default_spec.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { projectData } from 'spec/ide/mock_data'; +import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; +import { TEST_HOST } from 'spec/test_constants'; + +describe('ProjectAvatarDefault component', () => { + const Component = Vue.extend(ProjectAvatarDefault); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + project: projectData, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders identicon if project has no avatar_url', done => { + const expectedText = getFirstCharacterCapitalized(projectData.name); + + vm.project = { + ...vm.project, + avatar_url: null, + }; + + vm.$nextTick() + .then(() => { + const identiconEl = vm.$el.querySelector('.identicon'); + + expect(identiconEl).not.toBe(null); + expect(identiconEl.textContent.trim()).toEqual(expectedText); + }) + .then(done) + .catch(done.fail); + }); + + it('renders avatar image if project has avatar_url', done => { + const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; + + vm.project = { + ...vm.project, + avatar_url: avatarUrl, + }; + + vm.$nextTick() + .then(() => { + expect(vm.$el).toContainElement('.avatar'); + expect(vm.$el).not.toContainElement('.identicon'); + expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js index 656b57d764e..dc7652c77f7 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -12,7 +12,7 @@ const DEFAULT_PROPS = { tooltipPlacement: 'bottom', }; -describe('User Avatar Image Component', function () { +describe('User Avatar Image Component', function() { let vm; let UserAvatarImage; @@ -20,37 +20,37 @@ describe('User Avatar Image Component', function () { UserAvatarImage = Vue.extend(userAvatarImage); }); - describe('Initialization', function () { - beforeEach(function () { + describe('Initialization', function() { + beforeEach(function() { vm = mountComponent(UserAvatarImage, { ...DEFAULT_PROPS, }).$mount(); }); - it('should return a defined Vue component', function () { + it('should return a defined Vue component', function() { expect(vm).toBeDefined(); }); - it('should have <img> as a child element', function () { + it('should have <img> as a child element', function() { expect(vm.$el.tagName).toBe('IMG'); - expect(vm.$el.getAttribute('src')).toBe(DEFAULT_PROPS.imgSrc); - expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc); + expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt); }); - it('should properly compute tooltipContainer', function () { + it('should properly compute tooltipContainer', function() { expect(vm.tooltipContainer).toBe('body'); }); - it('should properly render tooltipContainer', function () { + it('should properly render tooltipContainer', function() { expect(vm.$el.getAttribute('data-container')).toBe('body'); }); - it('should properly compute avatarSizeClass', function () { + it('should properly compute avatarSizeClass', function() { expect(vm.avatarSizeClass).toBe('s99'); }); - it('should properly render img css', function () { + it('should properly render img css', function() { const { classList } = vm.$el; const containsAvatar = classList.contains('avatar'); const containsSizeClass = classList.contains('s99'); @@ -64,21 +64,21 @@ describe('User Avatar Image Component', function () { }); }); - describe('Initialization when lazy', function () { - beforeEach(function () { + describe('Initialization when lazy', function() { + beforeEach(function() { vm = mountComponent(UserAvatarImage, { ...DEFAULT_PROPS, lazy: true, }).$mount(); }); - it('should add lazy attributes', function () { + it('should add lazy attributes', function() { const { classList } = vm.$el; const lazyClass = classList.contains('lazy'); expect(lazyClass).toBe(true); expect(vm.$el.getAttribute('src')).toBe(placeholderImage); - expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc); + expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); }); }); }); diff --git a/spec/lib/bitbucket_server/client_spec.rb b/spec/lib/bitbucket_server/client_spec.rb new file mode 100644 index 00000000000..f926ae963a4 --- /dev/null +++ b/spec/lib/bitbucket_server/client_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' + +describe BitbucketServer::Client do + let(:base_uri) { 'https://test:7990/stash/' } + let(:options) { { base_uri: base_uri, user: 'bitbucket', password: 'mypassword' } } + let(:project) { 'SOME-PROJECT' } + let(:repo_slug) { 'my-repo' } + let(:headers) { { "Content-Type" => "application/json" } } + + subject { described_class.new(options) } + + describe '#pull_requests' do + let(:path) { "/projects/#{project}/repos/#{repo_slug}/pull-requests?state=ALL" } + + it 'requests a collection' do + expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :pull_request) + + subject.pull_requests(project, repo_slug) + end + + it 'throws an exception when connection fails' do + allow(BitbucketServer::Collection).to receive(:new).and_raise(OpenSSL::SSL::SSLError) + + expect { subject.pull_requests(project, repo_slug) }.to raise_error(described_class::ServerError) + end + end + + describe '#activities' do + let(:path) { "/projects/#{project}/repos/#{repo_slug}/pull-requests/1/activities" } + + it 'requests a collection' do + expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :activity) + + subject.activities(project, repo_slug, 1) + end + end + + describe '#repo' do + let(:path) { "/projects/#{project}/repos/#{repo_slug}" } + let(:url) { "#{base_uri}rest/api/1.0/projects/SOME-PROJECT/repos/my-repo" } + + it 'requests a specific repository' do + stub_request(:get, url).to_return(status: 200, headers: headers, body: '{}') + + subject.repo(project, repo_slug) + + expect(WebMock).to have_requested(:get, url) + end + end + + describe '#repos' do + let(:path) { "/repos" } + + it 'requests a collection' do + expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :repo) + + subject.repos + end + end + + describe '#create_branch' do + let(:branch) { 'test-branch' } + let(:sha) { '12345678' } + let(:url) { "#{base_uri}rest/api/1.0/projects/SOME-PROJECT/repos/my-repo/branches" } + + it 'requests Bitbucket to create a branch' do + stub_request(:post, url).to_return(status: 204, headers: headers, body: '{}') + + subject.create_branch(project, repo_slug, branch, sha) + + expect(WebMock).to have_requested(:post, url) + end + end + + describe '#delete_branch' do + let(:branch) { 'test-branch' } + let(:sha) { '12345678' } + let(:url) { "#{base_uri}rest/branch-utils/1.0/projects/SOME-PROJECT/repos/my-repo/branches" } + + it 'requests Bitbucket to create a branch' do + stub_request(:delete, url).to_return(status: 204, headers: headers, body: '{}') + + subject.delete_branch(project, repo_slug, branch, sha) + + expect(WebMock).to have_requested(:delete, url) + end + end +end diff --git a/spec/lib/bitbucket_server/connection_spec.rb b/spec/lib/bitbucket_server/connection_spec.rb new file mode 100644 index 00000000000..b5da4cb1a49 --- /dev/null +++ b/spec/lib/bitbucket_server/connection_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe BitbucketServer::Connection do + let(:options) { { base_uri: 'https://test:7990', user: 'bitbucket', password: 'mypassword' } } + let(:payload) { { 'test' => 1 } } + let(:headers) { { "Content-Type" => "application/json" } } + let(:url) { 'https://test:7990/rest/api/1.0/test?something=1' } + + subject { described_class.new(options) } + + describe '#get' do + it 'returns JSON body' do + WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: payload.to_json, status: 200, headers: headers) + + expect(subject.get(url, { something: 1 })).to eq(payload) + end + + it 'throws an exception if the response is not 200' do + WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: payload.to_json, status: 500, headers: headers) + + expect { subject.get(url) }.to raise_error(described_class::ConnectionError) + end + + it 'throws an exception if the response is not JSON' do + WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: 'bad data', status: 200, headers: headers) + + expect { subject.get(url) }.to raise_error(described_class::ConnectionError) + end + end + + describe '#post' do + let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } } + + it 'returns JSON body' do + WebMock.stub_request(:post, url).with(headers: headers).to_return(body: payload.to_json, status: 200, headers: headers) + + expect(subject.post(url, payload)).to eq(payload) + end + + it 'throws an exception if the response is not 200' do + WebMock.stub_request(:post, url).with(headers: headers).to_return(body: payload.to_json, status: 500, headers: headers) + + expect { subject.post(url, payload) }.to raise_error(described_class::ConnectionError) + end + end + + describe '#delete' do + let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } } + + context 'branch API' do + let(:branch_path) { '/projects/foo/repos/bar/branches' } + let(:branch_url) { 'https://test:7990/rest/branch-utils/1.0/projects/foo/repos/bar/branches' } + let(:path) { } + + it 'returns JSON body' do + WebMock.stub_request(:delete, branch_url).with(headers: headers).to_return(body: payload.to_json, status: 200, headers: headers) + + expect(subject.delete(:branches, branch_path, payload)).to eq(payload) + end + + it 'throws an exception if the response is not 200' do + WebMock.stub_request(:delete, branch_url).with(headers: headers).to_return(body: payload.to_json, status: 500, headers: headers) + + expect { subject.delete(:branches, branch_path, payload) }.to raise_error(described_class::ConnectionError) + end + end + end +end diff --git a/spec/lib/bitbucket_server/page_spec.rb b/spec/lib/bitbucket_server/page_spec.rb new file mode 100644 index 00000000000..cf419a9045b --- /dev/null +++ b/spec/lib/bitbucket_server/page_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe BitbucketServer::Page do + let(:response) { { 'values' => [{ 'description' => 'Test' }], 'isLastPage' => false, 'nextPageStart' => 2 } } + + before do + # Autoloading hack + BitbucketServer::Representation::PullRequest.new({}) + end + + describe '#items' do + it 'returns collection of needed objects' do + page = described_class.new(response, :pull_request) + + expect(page.items.first).to be_a(BitbucketServer::Representation::PullRequest) + expect(page.items.count).to eq(1) + end + end + + describe '#attrs' do + it 'returns attributes' do + page = described_class.new(response, :pull_request) + + expect(page.attrs.keys).to include(:isLastPage, :nextPageStart) + end + end + + describe '#next?' do + it 'returns true' do + page = described_class.new(response, :pull_request) + + expect(page.next?).to be_truthy + end + + it 'returns false' do + response['isLastPage'] = true + response.delete('nextPageStart') + page = described_class.new(response, :pull_request) + + expect(page.next?).to be_falsey + end + end + + describe '#next' do + it 'returns next attribute' do + page = described_class.new(response, :pull_request) + + expect(page.next).to eq(2) + end + end +end diff --git a/spec/lib/bitbucket_server/paginator_spec.rb b/spec/lib/bitbucket_server/paginator_spec.rb new file mode 100644 index 00000000000..2de50eba3c4 --- /dev/null +++ b/spec/lib/bitbucket_server/paginator_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe BitbucketServer::Paginator do + let(:last_page) { double(:page, next?: false, items: ['item_2']) } + let(:first_page) { double(:page, next?: true, next: last_page, items: ['item_1']) } + let(:connection) { instance_double(BitbucketServer::Connection) } + + describe '#items' do + let(:paginator) { described_class.new(connection, 'http://more-data', :pull_request) } + let(:page_attrs) { { 'isLastPage' => false, 'nextPageStart' => 1 } } + + it 'returns items and raises StopIteration in the end' do + allow(paginator).to receive(:fetch_next_page).and_return(first_page) + expect(paginator.items).to match(['item_1']) + + allow(paginator).to receive(:fetch_next_page).and_return(last_page) + expect(paginator.items).to match(['item_2']) + + allow(paginator).to receive(:fetch_next_page).and_return(nil) + expect { paginator.items }.to raise_error(StopIteration) + end + + it 'calls the connection with different offsets' do + expect(connection).to receive(:get).with('http://more-data', start: 0, limit: BitbucketServer::Paginator::PAGE_LENGTH).and_return(page_attrs) + + expect(paginator.items).to eq([]) + + expect(connection).to receive(:get).with('http://more-data', start: 1, limit: BitbucketServer::Paginator::PAGE_LENGTH).and_return({}) + + expect(paginator.items).to eq([]) + + expect { paginator.items }.to raise_error(StopIteration) + end + end +end diff --git a/spec/lib/bitbucket_server/representation/activity_spec.rb b/spec/lib/bitbucket_server/representation/activity_spec.rb new file mode 100644 index 00000000000..15c50e40472 --- /dev/null +++ b/spec/lib/bitbucket_server/representation/activity_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe BitbucketServer::Representation::Activity do + let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] } + let(:inline_comment) { activities.first } + let(:comment) { activities[3] } + let(:merge_event) { activities[4] } + + describe 'regular comment' do + subject { described_class.new(comment) } + + it { expect(subject.comment?).to be_truthy } + it { expect(subject.inline_comment?).to be_falsey } + it { expect(subject.comment).to be_a(BitbucketServer::Representation::Comment) } + it { expect(subject.created_at).to be_a(Time) } + end + + describe 'inline comment' do + subject { described_class.new(inline_comment) } + + it { expect(subject.comment?).to be_truthy } + it { expect(subject.inline_comment?).to be_truthy } + it { expect(subject.comment).to be_a(BitbucketServer::Representation::PullRequestComment) } + it { expect(subject.created_at).to be_a(Time) } + end + + describe 'merge event' do + subject { described_class.new(merge_event) } + + it { expect(subject.comment?).to be_falsey } + it { expect(subject.inline_comment?).to be_falsey } + it { expect(subject.committer_user).to eq('root') } + it { expect(subject.committer_email).to eq('test.user@example.com') } + it { expect(subject.merge_timestamp).to be_a(Time) } + it { expect(subject.created_at).to be_a(Time) } + it { expect(subject.merge_commit).to eq('839fa9a2d434eb697815b8fcafaecc51accfdbbc') } + end +end diff --git a/spec/lib/bitbucket_server/representation/comment_spec.rb b/spec/lib/bitbucket_server/representation/comment_spec.rb new file mode 100644 index 00000000000..53a20a1d80a --- /dev/null +++ b/spec/lib/bitbucket_server/representation/comment_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe BitbucketServer::Representation::Comment do + let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] } + let(:comment) { activities.first } + + subject { described_class.new(comment) } + + describe '#id' do + it { expect(subject.id).to eq(9) } + end + + describe '#author_username' do + it { expect(subject.author_username).to eq('root' ) } + end + + describe '#author_email' do + it { expect(subject.author_email).to eq('test.user@example.com' ) } + end + + describe '#note' do + it { expect(subject.note).to eq('is this a new line?') } + end + + describe '#created_at' do + it { expect(subject.created_at).to be_a(Time) } + end + + describe '#updated_at' do + it { expect(subject.created_at).to be_a(Time) } + end + + describe '#comments' do + it { expect(subject.comments.count).to eq(4) } + it { expect(subject.comments).to all( be_a(described_class) ) } + it { expect(subject.comments.map(&:note)).to match_array(["Hello world", "Ok", "hello", "hi"]) } + + # The thread should look like: + # + # is this a new line? (subject) + # -> Hello world (first) + # -> Ok (third) + # -> Hi (fourth) + # -> hello (second) + it 'comments have the right parent' do + first, second, third, fourth = subject.comments[0..4] + + expect(subject.parent_comment).to be_nil + expect(first.parent_comment).to eq(subject) + expect(second.parent_comment).to eq(subject) + expect(third.parent_comment).to eq(first) + expect(fourth.parent_comment).to eq(first) + end + end +end diff --git a/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb new file mode 100644 index 00000000000..bd7e3597486 --- /dev/null +++ b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe BitbucketServer::Representation::PullRequestComment do + let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] } + let(:comment) { activities.second } + + subject { described_class.new(comment) } + + describe '#id' do + it { expect(subject.id).to eq(7) } + end + + describe '#from_sha' do + it { expect(subject.from_sha).to eq('c5f4288162e2e6218180779c7f6ac1735bb56eab') } + end + + describe '#to_sha' do + it { expect(subject.to_sha).to eq('a4c2164330f2549f67c13f36a93884cf66e976be') } + end + + describe '#to?' do + it { expect(subject.to?).to be_falsey } + end + + describe '#from?' do + it { expect(subject.from?).to be_truthy } + end + + describe '#added?' do + it { expect(subject.added?).to be_falsey } + end + + describe '#removed?' do + it { expect(subject.removed?).to be_falsey } + end + + describe '#new_pos' do + it { expect(subject.new_pos).to eq(11) } + end + + describe '#old_pos' do + it { expect(subject.old_pos).to eq(9) } + end + + describe '#file_path' do + it { expect(subject.file_path).to eq('CHANGELOG.md') } + end +end diff --git a/spec/lib/bitbucket_server/representation/pull_request_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_spec.rb new file mode 100644 index 00000000000..4b8afdb006b --- /dev/null +++ b/spec/lib/bitbucket_server/representation/pull_request_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe BitbucketServer::Representation::PullRequest do + let(:sample_data) { JSON.parse(fixture_file('importers/bitbucket_server/pull_request.json')) } + + subject { described_class.new(sample_data) } + + describe '#author' do + it { expect(subject.author).to eq('root') } + end + + describe '#author_email' do + it { expect(subject.author_email).to eq('joe.montana@49ers.com') } + end + + describe '#description' do + it { expect(subject.description).to eq('Test') } + end + + describe '#iid' do + it { expect(subject.iid).to eq(7) } + end + + describe '#state' do + it { expect(subject.state).to eq('merged') } + + context 'declined pull requests' do + before do + sample_data['state'] = 'DECLINED' + end + + it 'returns closed' do + expect(subject.state).to eq('closed') + end + end + + context 'open pull requests' do + before do + sample_data['state'] = 'OPEN' + end + + it 'returns open' do + expect(subject.state).to eq('opened') + end + end + end + + describe '#merged?' do + it { expect(subject.merged?).to be_truthy } + end + + describe '#created_at' do + it { expect(subject.created_at.to_i).to eq(sample_data['createdDate'] / 1000) } + end + + describe '#updated_at' do + it { expect(subject.updated_at.to_i).to eq(sample_data['updatedDate'] / 1000) } + end + + describe '#title' do + it { expect(subject.title).to eq('Added a new line') } + end + + describe '#source_branch_name' do + it { expect(subject.source_branch_name).to eq('refs/heads/root/CODE_OF_CONDUCTmd-1530600625006') } + end + + describe '#source_branch_sha' do + it { expect(subject.source_branch_sha).to eq('074e2b4dddc5b99df1bf9d4a3f66cfc15481fdc8') } + end + + describe '#target_branch_name' do + it { expect(subject.target_branch_name).to eq('refs/heads/master') } + end + + describe '#target_branch_sha' do + it { expect(subject.target_branch_sha).to eq('839fa9a2d434eb697815b8fcafaecc51accfdbbc') } + end +end diff --git a/spec/lib/bitbucket_server/representation/repo_spec.rb b/spec/lib/bitbucket_server/representation/repo_spec.rb new file mode 100644 index 00000000000..3ac1030fbb0 --- /dev/null +++ b/spec/lib/bitbucket_server/representation/repo_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe BitbucketServer::Representation::Repo do + let(:sample_data) do + <<~DATA + { + "slug": "rouge", + "id": 1, + "name": "rouge", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "TEST", + "id": 1, + "name": "test", + "description": "Test", + "public": false, + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/projects/TEST" + } + ] + } + }, + "public": false, + "links": { + "clone": [ + { + "href": "http://root@localhost:7990/scm/test/rouge.git", + "name": "http" + }, + { + "href": "ssh://git@localhost:7999/test/rouge.git", + "name": "ssh" + } + ], + "self": [ + { + "href": "http://localhost:7990/projects/TEST/repos/rouge/browse" + } + ] + } + } + DATA + end + + subject { described_class.new(JSON.parse(sample_data)) } + + describe '#project_key' do + it { expect(subject.project_key).to eq('TEST') } + end + + describe '#project_name' do + it { expect(subject.project_name).to eq('test') } + end + + describe '#slug' do + it { expect(subject.slug).to eq('rouge') } + end + + describe '#browse_url' do + it { expect(subject.browse_url).to eq('http://localhost:7990/projects/TEST/repos/rouge/browse') } + end + + describe '#clone_url' do + it { expect(subject.clone_url).to eq('http://root@localhost:7990/scm/test/rouge.git') } + end + + describe '#description' do + it { expect(subject.description).to eq('Test') } + end + + describe '#full_name' do + it { expect(subject.full_name).to eq('test/rouge') } + end +end diff --git a/spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb b/spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb new file mode 100644 index 00000000000..dae754112dc --- /dev/null +++ b/spec/lib/gitlab/background_migration/remove_restricted_todos_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BackgroundMigration::RemoveRestrictedTodos, :migration, schema: 20180704204006 do + let(:projects) { table(:projects) } + let(:users) { table(:users) } + let(:todos) { table(:todos) } + let(:issues) { table(:issues) } + let(:assignees) { table(:issue_assignees) } + let(:project_authorizations) { table(:project_authorizations) } + let(:project_features) { table(:project_features) } + + let(:todo_params) { { author_id: 1, target_type: 'Issue', action: 1, state: :pending } } + + before do + users.create(id: 1, email: 'user@example.com', projects_limit: 10) + users.create(id: 2, email: 'reporter@example.com', projects_limit: 10) + users.create(id: 3, email: 'guest@example.com', projects_limit: 10) + + projects.create!(id: 1, name: 'project-1', path: 'project-1', visibility_level: 0, namespace_id: 1) + projects.create!(id: 2, name: 'project-2', path: 'project-2', visibility_level: 0, namespace_id: 1) + + issues.create(id: 1, project_id: 1) + issues.create(id: 2, project_id: 2) + + project_authorizations.create(user_id: 2, project_id: 2, access_level: 20) # reporter + project_authorizations.create(user_id: 3, project_id: 2, access_level: 10) # guest + + todos.create(todo_params.merge(user_id: 1, project_id: 1, target_id: 1)) # out of project ids range + todos.create(todo_params.merge(user_id: 1, project_id: 2, target_id: 2)) # non member + todos.create(todo_params.merge(user_id: 2, project_id: 2, target_id: 2)) # reporter + todos.create(todo_params.merge(user_id: 3, project_id: 2, target_id: 2)) # guest + end + + subject { described_class.new.perform(2, 5) } + + context 'when a project is private' do + it 'removes todos of users without project access' do + expect { subject }.to change { Todo.count }.from(4).to(3) + end + + context 'with a confidential issue' do + it 'removes todos of users without project access and guests for confidential issues' do + issues.create(id: 3, project_id: 2, confidential: true) + issues.create(id: 4, project_id: 1, confidential: true) # not in the batch + todos.create(todo_params.merge(user_id: 3, project_id: 2, target_id: 3)) + todos.create(todo_params.merge(user_id: 2, project_id: 2, target_id: 3)) + todos.create(todo_params.merge(user_id: 1, project_id: 1, target_id: 4)) + + expect { subject }.to change { Todo.count }.from(7).to(5) + end + end + end + + context 'when a project is public' do + before do + projects.find(2).update_attribute(:visibility_level, 20) + end + + context 'when all features have the same visibility as the project, no confidential issues' do + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end + end + + context 'with confidential issues' do + before do + users.create(id: 4, email: 'author@example.com', projects_limit: 10) + users.create(id: 5, email: 'assignee@example.com', projects_limit: 10) + issues.create(id: 3, project_id: 2, confidential: true, author_id: 4) + assignees.create(user_id: 5, issue_id: 3) + + todos.create(todo_params.merge(user_id: 1, project_id: 2, target_id: 3)) # to be deleted + todos.create(todo_params.merge(user_id: 2, project_id: 2, target_id: 3)) # authorized user + todos.create(todo_params.merge(user_id: 3, project_id: 2, target_id: 3)) # to be deleted guest + todos.create(todo_params.merge(user_id: 4, project_id: 2, target_id: 3)) # conf issue author + todos.create(todo_params.merge(user_id: 5, project_id: 2, target_id: 3)) # conf issue assignee + end + + it 'removes confidential issue todos for non authorized users' do + expect { subject }.to change { Todo.count }.from(9).to(7) + end + end + + context 'features visibility restrictions' do + before do + todo_params.merge!(project_id: 2, user_id: 1, target_id: 3) + todos.create(todo_params.merge(user_id: 1, target_id: 3, target_type: 'MergeRequest')) + todos.create(todo_params.merge(user_id: 1, target_id: 3, target_type: 'Commit')) + end + + context 'when issues are restricted to project members' do + before do + project_features.create(issues_access_level: 10, project_id: 2) + end + + it 'removes non members issue todos' do + expect { subject }.to change { Todo.count }.from(6).to(5) + end + end + + context 'when merge requests are restricted to project members' do + before do + project_features.create(merge_requests_access_level: 10, project_id: 2) + end + + it 'removes non members issue todos' do + expect { subject }.to change { Todo.count }.from(6).to(5) + end + end + + context 'when repository and merge requests are restricted to project members' do + before do + project_features.create(repository_access_level: 10, merge_requests_access_level: 10, project_id: 2) + end + + it 'removes non members commit and merge requests todos' do + expect { subject }.to change { Todo.count }.from(6).to(4) + end + end + end + end +end diff --git a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb new file mode 100644 index 00000000000..70423823b89 --- /dev/null +++ b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb @@ -0,0 +1,291 @@ +require 'spec_helper' + +describe Gitlab::BitbucketServerImport::Importer do + include ImportSpecHelper + + let(:project) { create(:project, :repository, import_url: 'http://my-bitbucket') } + let(:now) { Time.now.utc.change(usec: 0) } + let(:project_key) { 'TEST' } + let(:repo_slug) { 'rouge' } + let(:sample) { RepoHelpers.sample_compare } + + subject { described_class.new(project, recover_missing_commits: true) } + + before do + data = project.create_or_update_import_data( + data: { project_key: project_key, repo_slug: repo_slug }, + credentials: { base_uri: 'http://my-bitbucket', user: 'bitbucket', password: 'test' } + ) + data.save + project.save + end + + describe '#import_repository' do + before do + expect(subject).to receive(:import_pull_requests) + expect(subject).to receive(:delete_temp_branches) + end + + it 'adds a remote' do + expect(project.repository).to receive(:fetch_as_mirror) + .with('http://bitbucket:test@my-bitbucket', + refmap: [:heads, :tags, '+refs/pull-requests/*/to:refs/merge-requests/*/head'], + remote_name: 'bitbucket_server') + + subject.execute + end + end + + describe '#import_pull_requests' do + before do + allow(subject).to receive(:import_repository) + allow(subject).to receive(:delete_temp_branches) + allow(subject).to receive(:restore_branches) + + pull_request = instance_double( + BitbucketServer::Representation::PullRequest, + iid: 10, + source_branch_sha: sample.commits.last, + source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch, + target_branch_sha: sample.commits.first, + target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch, + title: 'This is a title', + description: 'This is a test pull request', + state: 'merged', + author: 'Test Author', + author_email: project.owner.email, + created_at: Time.now, + updated_at: Time.now, + merged?: true) + + allow(subject.client).to receive(:pull_requests).and_return([pull_request]) + + @merge_event = instance_double( + BitbucketServer::Representation::Activity, + comment?: false, + merge_event?: true, + committer_email: project.owner.email, + merge_timestamp: now, + merge_commit: '12345678' + ) + + @pr_note = instance_double( + BitbucketServer::Representation::Comment, + note: 'Hello world', + author_email: 'unknown@gmail.com', + author_username: 'The Flash', + comments: [], + created_at: now, + updated_at: now, + parent_comment: nil) + + @pr_comment = instance_double( + BitbucketServer::Representation::Activity, + comment?: true, + inline_comment?: false, + merge_event?: false, + comment: @pr_note) + end + + it 'imports merge event' do + expect(subject.client).to receive(:activities).and_return([@merge_event]) + + expect { subject.execute }.to change { MergeRequest.count }.by(1) + + merge_request = MergeRequest.first + expect(merge_request.metrics.merged_by).to eq(project.owner) + expect(merge_request.metrics.merged_at).to eq(@merge_event.merge_timestamp) + expect(merge_request.merge_commit_sha).to eq('12345678') + end + + it 'imports comments' do + expect(subject.client).to receive(:activities).and_return([@pr_comment]) + + expect { subject.execute }.to change { MergeRequest.count }.by(1) + + merge_request = MergeRequest.first + expect(merge_request.notes.count).to eq(1) + note = merge_request.notes.first + expect(note.note).to end_with(@pr_note.note) + expect(note.author).to eq(project.owner) + expect(note.created_at).to eq(@pr_note.created_at) + expect(note.updated_at).to eq(@pr_note.created_at) + end + + it 'imports threaded discussions' do + reply = instance_double( + BitbucketServer::Representation::PullRequestComment, + author_email: 'someuser@gitlab.com', + author_username: 'Batman', + note: 'I agree', + created_at: now, + updated_at: now) + + # https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad + inline_note = instance_double( + BitbucketServer::Representation::PullRequestComment, + file_type: 'ADDED', + from_sha: sample.commits.first, + to_sha: sample.commits.last, + file_path: '.gitmodules', + old_pos: nil, + new_pos: 4, + note: 'Hello world', + author_email: 'unknown@gmail.com', + author_username: 'Superman', + comments: [reply], + created_at: now, + updated_at: now, + parent_comment: nil) + + allow(reply).to receive(:parent_comment).and_return(inline_note) + + inline_comment = instance_double( + BitbucketServer::Representation::Activity, + comment?: true, + inline_comment?: true, + merge_event?: false, + comment: inline_note) + + expect(subject.client).to receive(:activities).and_return([inline_comment]) + + expect { subject.execute }.to change { MergeRequest.count }.by(1) + + merge_request = MergeRequest.first + expect(merge_request.notes.count).to eq(2) + expect(merge_request.notes.map(&:discussion_id).uniq.count).to eq(1) + + notes = merge_request.notes.order(:id).to_a + start_note = notes.first + expect(start_note.type).to eq('DiffNote') + expect(start_note.note).to end_with(inline_note.note) + expect(start_note.created_at).to eq(inline_note.created_at) + expect(start_note.updated_at).to eq(inline_note.updated_at) + expect(start_note.position.base_sha).to eq(inline_note.from_sha) + expect(start_note.position.start_sha).to eq(inline_note.from_sha) + expect(start_note.position.head_sha).to eq(inline_note.to_sha) + expect(start_note.position.old_line).to be_nil + expect(start_note.position.new_line).to eq(inline_note.new_pos) + + reply_note = notes.last + # Make sure author and reply context is included + expect(reply_note.note).to start_with("*By #{reply.author_username} (#{reply.author_email})*\n\n") + expect(reply_note.note).to end_with("> #{inline_note.note}\n\n#{reply.note}") + expect(reply_note.author).to eq(project.owner) + expect(reply_note.created_at).to eq(reply.created_at) + expect(reply_note.updated_at).to eq(reply.created_at) + expect(reply_note.position.base_sha).to eq(inline_note.from_sha) + expect(reply_note.position.start_sha).to eq(inline_note.from_sha) + expect(reply_note.position.head_sha).to eq(inline_note.to_sha) + expect(reply_note.position.old_line).to be_nil + expect(reply_note.position.new_line).to eq(inline_note.new_pos) + end + + it 'falls back to comments if diff comments fail to validate' do + reply = instance_double( + BitbucketServer::Representation::Comment, + author_email: 'someuser@gitlab.com', + author_username: 'Aquaman', + note: 'I agree', + created_at: now, + updated_at: now) + + # https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad + inline_note = instance_double( + BitbucketServer::Representation::PullRequestComment, + file_type: 'REMOVED', + from_sha: sample.commits.first, + to_sha: sample.commits.last, + file_path: '.gitmodules', + old_pos: 8, + new_pos: 9, + note: 'This is a note with an invalid line position.', + author_email: project.owner.email, + author_username: 'Owner', + comments: [reply], + created_at: now, + updated_at: now, + parent_comment: nil) + + inline_comment = instance_double( + BitbucketServer::Representation::Activity, + comment?: true, + inline_comment?: true, + merge_event?: false, + comment: inline_note) + + allow(reply).to receive(:parent_comment).and_return(inline_note) + + expect(subject.client).to receive(:activities).and_return([inline_comment]) + + expect { subject.execute }.to change { MergeRequest.count }.by(1) + + merge_request = MergeRequest.first + expect(merge_request.notes.count).to eq(2) + notes = merge_request.notes + + expect(notes.first.note).to start_with('*Comment on .gitmodules') + expect(notes.second.note).to start_with('*Comment on .gitmodules') + end + end + + describe 'inaccessible branches' do + let(:id) { 10 } + let(:temp_branch_from) { "gitlab/import/pull-request/#{id}/from" } + let(:temp_branch_to) { "gitlab/import/pull-request/#{id}/to" } + + before do + pull_request = instance_double( + BitbucketServer::Representation::PullRequest, + iid: id, + source_branch_sha: '12345678', + source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch, + target_branch_sha: '98765432', + target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch, + title: 'This is a title', + description: 'This is a test pull request', + state: 'merged', + author: 'Test Author', + author_email: project.owner.email, + created_at: Time.now, + updated_at: Time.now, + merged?: true) + + expect(subject.client).to receive(:pull_requests).and_return([pull_request]) + expect(subject.client).to receive(:activities).and_return([]) + expect(subject).to receive(:import_repository).twice + end + + it '#restore_branches' do + expect(subject).to receive(:restore_branches).and_call_original + expect(subject).to receive(:delete_temp_branches) + expect(subject.client).to receive(:create_branch) + .with(project_key, repo_slug, + temp_branch_from, + '12345678') + expect(subject.client).to receive(:create_branch) + .with(project_key, repo_slug, + temp_branch_to, + '98765432') + + expect { subject.execute }.to change { MergeRequest.count }.by(1) + end + + it '#delete_temp_branches' do + expect(subject.client).to receive(:create_branch).twice + expect(subject).to receive(:delete_temp_branches).and_call_original + expect(subject.client).to receive(:delete_branch) + .with(project_key, repo_slug, + temp_branch_from, + '12345678') + expect(subject.client).to receive(:delete_branch) + .with(project_key, repo_slug, + temp_branch_to, + '98765432') + expect(project.repository).to receive(:delete_branch).with(temp_branch_from) + expect(project.repository).to receive(:delete_branch).with(temp_branch_to) + + expect { subject.execute }.to change { MergeRequest.count }.by(1) + end + end +end diff --git a/spec/lib/gitlab/cleanup/remote_uploads_spec.rb b/spec/lib/gitlab/cleanup/remote_uploads_spec.rb new file mode 100644 index 00000000000..8d03baeb07b --- /dev/null +++ b/spec/lib/gitlab/cleanup/remote_uploads_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Gitlab::Cleanup::RemoteUploads do + context 'when object_storage is enabled' do + let(:connection) { double } + let(:directory) { double } + let!(:uploads) do + [ + create(:upload, path: 'dir/file1', store: ObjectStorage::Store::REMOTE), + create(:upload, path: 'dir/file2', store: ObjectStorage::Store::LOCAL) + ] + end + let(:remote_files) do + [ + double(key: 'dir/file1'), + double(key: 'dir/file2'), + double(key: 'dir/file3'), + double(key: 'lost_and_found/dir/file3') + ] + end + + before do + stub_uploads_object_storage(FileUploader) + + expect(::Fog::Storage).to receive(:new).and_return(connection) + + expect(connection).to receive(:directories).and_return(double(get: directory)) + expect(directory).to receive(:files).and_return(remote_files) + end + + context 'when dry_run is set to false' do + subject { described_class.new.run!(dry_run: false) } + + it 'moves files that are not in uploads table' do + expect(remote_files[0]).not_to receive(:copy) + expect(remote_files[0]).not_to receive(:destroy) + expect(remote_files[1]).to receive(:copy) + expect(remote_files[1]).to receive(:destroy) + expect(remote_files[2]).to receive(:copy) + expect(remote_files[2]).to receive(:destroy) + expect(remote_files[3]).not_to receive(:copy) + expect(remote_files[3]).not_to receive(:destroy) + + subject + end + end + + context 'when dry_run is set to true' do + subject { described_class.new.run!(dry_run: true) } + + it 'does not move filese' do + expect(remote_files[0]).not_to receive(:copy) + expect(remote_files[0]).not_to receive(:destroy) + expect(remote_files[1]).not_to receive(:copy) + expect(remote_files[1]).not_to receive(:destroy) + expect(remote_files[2]).not_to receive(:copy) + expect(remote_files[2]).not_to receive(:destroy) + expect(remote_files[3]).not_to receive(:copy) + expect(remote_files[3]).not_to receive(:destroy) + + subject + end + end + end + + context 'when object_storage is not enabled' do + it 'does not connect to any storage' do + expect(::Fog::Storage).not_to receive(:new) + + subject + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index b3e3ead9c5e..e9a1932407d 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -89,6 +89,7 @@ merge_requests: - merge_request_diff - events - merge_requests_closing_issues +- cached_closes_issues - metrics - timelogs - head_pipeline diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb index 25827423914..94abf9679c4 100644 --- a/spec/lib/gitlab/import_sources_spec.rb +++ b/spec/lib/gitlab/import_sources_spec.rb @@ -5,15 +5,16 @@ describe Gitlab::ImportSources do it 'returns a hash' do expected = { - 'GitHub' => 'github', - 'Bitbucket' => 'bitbucket', - 'GitLab.com' => 'gitlab', - 'Google Code' => 'google_code', - 'FogBugz' => 'fogbugz', - 'Repo by URL' => 'git', - 'GitLab export' => 'gitlab_project', - 'Gitea' => 'gitea', - 'Manifest file' => 'manifest' + 'GitHub' => 'github', + 'Bitbucket Cloud' => 'bitbucket', + 'Bitbucket Server' => 'bitbucket_server', + 'GitLab.com' => 'gitlab', + 'Google Code' => 'google_code', + 'FogBugz' => 'fogbugz', + 'Repo by URL' => 'git', + 'GitLab export' => 'gitlab_project', + 'Gitea' => 'gitea', + 'Manifest file' => 'manifest' } expect(described_class.options).to eq(expected) @@ -26,6 +27,7 @@ describe Gitlab::ImportSources do %w( github bitbucket + bitbucket_server gitlab google_code fogbugz @@ -45,6 +47,7 @@ describe Gitlab::ImportSources do %w( github bitbucket + bitbucket_server gitlab google_code fogbugz @@ -60,6 +63,7 @@ describe Gitlab::ImportSources do import_sources = { 'github' => Gitlab::GithubImport::ParallelImporter, 'bitbucket' => Gitlab::BitbucketImport::Importer, + 'bitbucket_server' => Gitlab::BitbucketServerImport::Importer, 'gitlab' => Gitlab::GitlabImport::Importer, 'google_code' => Gitlab::GoogleCodeImport::Importer, 'fogbugz' => Gitlab::FogbugzImport::Importer, @@ -79,7 +83,8 @@ describe Gitlab::ImportSources do describe '.title' do import_sources = { 'github' => 'GitHub', - 'bitbucket' => 'Bitbucket', + 'bitbucket' => 'Bitbucket Cloud', + 'bitbucket_server' => 'Bitbucket Server', 'gitlab' => 'GitLab.com', 'google_code' => 'Google Code', 'fogbugz' => 'FogBugz', @@ -97,7 +102,7 @@ describe Gitlab::ImportSources do end describe 'imports_repository? checker' do - let(:allowed_importers) { %w[github gitlab_project] } + let(:allowed_importers) { %w[github gitlab_project bitbucket_server] } it 'fails if any importer other than the allowed ones implements this method' do current_importers = described_class.values.select { |kind| described_class.importer(kind).try(:imports_repository?) } diff --git a/spec/lib/gitlab/kubernetes/config_map_spec.rb b/spec/lib/gitlab/kubernetes/config_map_spec.rb index e253b291277..fe65d03875f 100644 --- a/spec/lib/gitlab/kubernetes/config_map_spec.rb +++ b/spec/lib/gitlab/kubernetes/config_map_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::Kubernetes::ConfigMap do let(:kubeclient) { double('kubernetes client') } let(:application) { create(:clusters_applications_prometheus) } - let(:config_map) { described_class.new(application.name, application.values) } + let(:config_map) { described_class.new(application.name, application.files) } let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE } let(:metadata) do @@ -15,7 +15,7 @@ describe Gitlab::Kubernetes::ConfigMap do end describe '#generate' do - let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) } + let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: application.files) } subject { config_map.generate } it 'should build a Kubeclient Resource' do diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb index 6e9b4ca0869..341f71a3e49 100644 --- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb @@ -39,7 +39,7 @@ describe Gitlab::Kubernetes::Helm::Api do end context 'with a ConfigMap' do - let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.values).generate } + let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.files).generate } it 'creates a ConfigMap on kubeclient' do expect(client).to receive(:create_config_map).with(resource).once diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb index 7be8be54d5e..d50616e95e8 100644 --- a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb @@ -2,7 +2,25 @@ require 'spec_helper' describe Gitlab::Kubernetes::Helm::BaseCommand do let(:application) { create(:clusters_applications_helm) } - let(:base_command) { described_class.new(application.name) } + let(:test_class) do + Class.new do + include Gitlab::Kubernetes::Helm::BaseCommand + + def name + "test-class-name" + end + + def files + { + some: 'value' + } + end + end + end + + let(:base_command) do + test_class.new + end subject { base_command } @@ -18,15 +36,9 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do end end - describe '#config_map?' do - subject { base_command.config_map? } - - it { is_expected.to be_falsy } - end - describe '#pod_name' do subject { base_command.pod_name } - it { is_expected.to eq('install-helm') } + it { is_expected.to eq('install-test-class-name') } end end diff --git a/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb b/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb new file mode 100644 index 00000000000..167bee22fc3 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Gitlab::Kubernetes::Helm::Certificate do + describe '.generate_root' do + subject { described_class.generate_root } + + it 'should generate a root CA that expires a long way in the future' do + expect(subject.cert.not_after).to be > 999.years.from_now + end + end + + describe '#issue' do + subject { described_class.generate_root.issue } + + it 'should generate a cert that expires soon' do + expect(subject.cert.not_after).to be < 60.minutes.from_now + end + + context 'passing in INFINITE_EXPIRY' do + subject { described_class.generate_root.issue(expires_in: described_class::INFINITE_EXPIRY) } + + it 'should generate a cert that expires a long way in the future' do + expect(subject.cert.not_after).to be > 999.years.from_now + end + end + end +end diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb index 89e36a298f8..dcbc046cf00 100644 --- a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb @@ -2,9 +2,9 @@ require 'spec_helper' describe Gitlab::Kubernetes::Helm::InitCommand do let(:application) { create(:clusters_applications_helm) } - let(:commands) { 'helm init >/dev/null' } + let(:commands) { 'helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem >/dev/null' } - subject { described_class.new(application.name) } + subject { described_class.new(name: application.name, files: {}) } it_behaves_like 'helm commands' end diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb index cd456a45287..982e2f41043 100644 --- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb @@ -1,83 +1,82 @@ require 'rails_helper' describe Gitlab::Kubernetes::Helm::InstallCommand do - let(:application) { create(:clusters_applications_prometheus) } - let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE } - let(:install_command) { application.install_command } + let(:files) { { 'ca.pem': 'some file content' } } + let(:repository) { 'https://repository.example.com' } + let(:version) { '1.2.3' } + + let(:install_command) do + described_class.new( + name: 'app-name', + chart: 'chart-name', + files: files, + version: version, repository: repository + ) + end subject { install_command } - context 'for ingress' do - let(:application) { create(:clusters_applications_ingress) } - - it_behaves_like 'helm commands' do - let(:commands) do - <<~EOS - helm init --client-only >/dev/null - helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null - EOS - end + it_behaves_like 'helm commands' do + let(:commands) do + <<~EOS + helm init --client-only >/dev/null + helm repo add app-name https://repository.example.com + helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null + EOS end end - context 'for prometheus' do - let(:application) { create(:clusters_applications_prometheus) } + context 'when there is no repository' do + let(:repository) { nil } it_behaves_like 'helm commands' do let(:commands) do <<~EOS helm init --client-only >/dev/null - helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null + helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null EOS end end end - context 'for runner' do - let(:ci_runner) { create(:ci_runner) } - let(:application) { create(:clusters_applications_runner, runner: ci_runner) } + context 'when there is no ca.pem file' do + let(:files) { { 'file.txt': 'some content' } } it_behaves_like 'helm commands' do let(:commands) do <<~EOS helm init --client-only >/dev/null - helm repo add #{application.name} #{application.repository} - helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null + helm repo add app-name https://repository.example.com + helm install chart-name --name app-name --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null EOS end end end - context 'for jupyter' do - let(:application) { create(:clusters_applications_jupyter) } + context 'when there is no version' do + let(:version) { nil } it_behaves_like 'helm commands' do let(:commands) do <<~EOS helm init --client-only >/dev/null - helm repo add #{application.name} #{application.repository} - helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null + helm repo add app-name https://repository.example.com + helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null EOS end end end - describe '#config_map?' do - subject { install_command.config_map? } - - it { is_expected.to be_truthy } - end - describe '#config_map_resource' do let(:metadata) do { - name: "values-content-configuration-#{application.name}", - namespace: namespace, - labels: { name: "values-content-configuration-#{application.name}" } + name: "values-content-configuration-app-name", + namespace: 'gitlab-managed-apps', + labels: { name: "values-content-configuration-app-name" } } end - let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) } + let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) } subject { install_command.config_map_resource } diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb index 43adc80d576..ec64193c0b2 100644 --- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb @@ -2,14 +2,13 @@ require 'rails_helper' describe Gitlab::Kubernetes::Helm::Pod do describe '#generate' do - let(:cluster) { create(:cluster) } - let(:app) { create(:clusters_applications_prometheus, cluster: cluster) } + let(:app) { create(:clusters_applications_prometheus) } let(:command) { app.install_command } let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE } subject { described_class.new(command, namespace) } - shared_examples 'helm pod' do + context 'with a command' do it 'should generate a Kubeclient::Resource' do expect(subject.generate).to be_a_kind_of(Kubeclient::Resource) end @@ -41,10 +40,6 @@ describe Gitlab::Kubernetes::Helm::Pod do spec = subject.generate.spec expect(spec.restartPolicy).to eq('Never') end - end - - context 'with a install command' do - it_behaves_like 'helm pod' it 'should include volumes for the container' do container = subject.generate.spec.containers.first @@ -60,24 +55,8 @@ describe Gitlab::Kubernetes::Helm::Pod do it 'should mount configMap specification in the volume' do volume = subject.generate.spec.volumes.first expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}") - expect(volume.configMap['items'].first['key']).to eq('values') - expect(volume.configMap['items'].first['path']).to eq('values.yaml') - end - end - - context 'with a init command' do - let(:app) { create(:clusters_applications_helm, cluster: cluster) } - - it_behaves_like 'helm pod' - - it 'should not include volumeMounts inside the container' do - container = subject.generate.spec.containers.first - expect(container.volumeMounts).to be_nil - end - - it 'should not a volume inside the specification' do - spec = subject.generate.spec - expect(spec.volumes).to be_nil + expect(volume.configMap['items'].first['key']).to eq(:'values.yaml') + expect(volume.configMap['items'].first['path']).to eq(:'values.yaml') end end end diff --git a/spec/migrations/normalize_ldap_extern_uids_spec.rb b/spec/migrations/normalize_ldap_extern_uids_spec.rb index c6ea1e3e49e..a23a5d54e0a 100644 --- a/spec/migrations/normalize_ldap_extern_uids_spec.rb +++ b/spec/migrations/normalize_ldap_extern_uids_spec.rb @@ -27,11 +27,11 @@ describe NormalizeLdapExternUids, :migration, :sidekiq do migrate! expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([described_class::MIGRATION, [1, 2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(5.minutes.from_now.to_f) + expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(2.minutes.from_now.to_f) expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([described_class::MIGRATION, [3, 4]]) - expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(10.minutes.from_now.to_f) + expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(4.minutes.from_now.to_f) expect(BackgroundMigrationWorker.jobs[2]['args']).to eq([described_class::MIGRATION, [5, 5]]) - expect(BackgroundMigrationWorker.jobs[2]['at']).to eq(15.minutes.from_now.to_f) + expect(BackgroundMigrationWorker.jobs[2]['at']).to eq(6.minutes.from_now.to_f) expect(BackgroundMigrationWorker.jobs.size).to eq 3 end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 3512ba6aee5..77b7332a761 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1856,9 +1856,7 @@ describe Ci::Pipeline, :mailer do context 'when pipeline has builds with test reports' do before do - create(:ci_build, pipeline: pipeline, project: project).tap do |build| - create(:ci_job_artifact, :junit, job: build, project: build.project) - end + create(:ci_build, :test_reports, pipeline: pipeline, project: project) end context 'when pipeline status is running' do @@ -1875,6 +1873,22 @@ describe Ci::Pipeline, :mailer do end context 'when pipeline does not have builds with test reports' do + before do + create(:ci_build, :artifacts, pipeline: pipeline, project: project) + end + + let(:pipeline) { create(:ci_pipeline, :success, project: project) } + + it { is_expected.to be_falsey } + end + + context 'when retried build has test reports' do + before do + create(:ci_build, :retried, :test_reports, pipeline: pipeline, project: project) + end + + let(:pipeline) { create(:ci_pipeline, :success, project: project) } + it { is_expected.to be_falsey } end end @@ -1883,14 +1897,12 @@ describe Ci::Pipeline, :mailer do subject { pipeline.test_reports } context 'when pipeline has multiple builds with test reports' do - before do - create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project).tap do |build| - create(:ci_job_artifact, :junit, job: build, project: build.project) - end + let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) } + let!(:build_java) { create(:ci_build, :success, name: 'java', pipeline: pipeline, project: project) } - create(:ci_build, :success, name: 'java', pipeline: pipeline, project: project).tap do |build| - create(:ci_job_artifact, :junit_with_ant, job: build, project: build.project) - end + before do + create(:ci_job_artifact, :junit, job: build_rspec, project: project) + create(:ci_job_artifact, :junit_with_ant, job: build_java, project: project) end it 'returns test reports with collected data' do @@ -1898,6 +1910,17 @@ describe Ci::Pipeline, :mailer do expect(subject.success_count).to be(5) expect(subject.failed_count).to be(2) end + + context 'when builds are retried' do + let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) } + let!(:build_java) { create(:ci_build, :retried, :success, name: 'java', pipeline: pipeline, project: project) } + + it 'does not take retried builds into account' do + expect(subject.total_count).to be(0) + expect(subject.success_count).to be(0) + expect(subject.failed_count).to be(0) + end + end end context 'when pipeline does not have any builds with test reports' do diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb index 0eb1e3876e2..e5b2bdc8a4e 100644 --- a/spec/models/clusters/applications/helm_spec.rb +++ b/spec/models/clusters/applications/helm_spec.rb @@ -6,13 +6,24 @@ describe Clusters::Applications::Helm do describe '.installed' do subject { described_class.installed } - let!(:cluster) { create(:clusters_applications_helm, :installed) } + let!(:installed_cluster) { create(:clusters_applications_helm, :installed) } before do create(:clusters_applications_helm, :errored) end - it { is_expected.to contain_exactly(cluster) } + it { is_expected.to contain_exactly(installed_cluster) } + end + + describe '#issue_client_cert' do + let(:application) { create(:clusters_applications_helm) } + subject { application.issue_client_cert } + + it 'returns a new cert' do + is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::Certificate) + expect(subject.cert_string).not_to eq(application.ca_cert) + expect(subject.key_string).not_to eq(application.ca_key) + end end describe '#install_command' do @@ -25,5 +36,16 @@ describe Clusters::Applications::Helm do it 'should be initialized with 1 arguments' do expect(subject.name).to eq('helm') end + + it 'should have cert files' do + expect(subject.files[:'ca.pem']).to be_present + expect(subject.files[:'ca.pem']).to eq(helm.ca_cert) + + expect(subject.files[:'cert.pem']).to be_present + expect(subject.files[:'key.pem']).to be_present + + cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem']) + expect(cert.not_after).to be > 999.years.from_now + end end end diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index d378248d5d6..21f75ced8c3 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -88,7 +88,7 @@ describe Clusters::Applications::Ingress do expect(subject.name).to eq('ingress') expect(subject.chart).to eq('stable/nginx-ingress') expect(subject.version).to eq('0.23.0') - expect(subject.values).to eq(ingress.values) + expect(subject.files).to eq(ingress.files) end context 'application failed to install previously' do @@ -100,14 +100,40 @@ describe Clusters::Applications::Ingress do end end - describe '#values' do - subject { ingress.values } + describe '#files' do + let(:application) { ingress } + let(:values) { subject[:'values.yaml'] } - it 'should include ingress valid keys' do - is_expected.to include('image') - is_expected.to include('repository') - is_expected.to include('stats') - is_expected.to include('podAnnotations') + subject { application.files } + + it 'should include ingress valid keys in values' do + expect(values).to include('image') + expect(values).to include('repository') + expect(values).to include('stats') + expect(values).to include('podAnnotations') + end + + context 'when the helm application does not have a ca_cert' do + before do + application.cluster.application_helm.ca_cert = nil + end + + it 'should not include cert files' do + expect(subject[:'ca.pem']).not_to be_present + expect(subject[:'cert.pem']).not_to be_present + expect(subject[:'key.pem']).not_to be_present + end + end + + it 'should include cert files' do + expect(subject[:'ca.pem']).to be_present + expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert) + + expect(subject[:'cert.pem']).to be_present + expect(subject[:'key.pem']).to be_present + + cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem']) + expect(cert.not_after).to be < 60.minutes.from_now end end end diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index e0d57ac65f7..027b732681b 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -52,7 +52,7 @@ describe Clusters::Applications::Jupyter do expect(subject.chart).to eq('jupyter/jupyterhub') expect(subject.version).to eq('v0.6') expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/') - expect(subject.values).to eq(jupyter.values) + expect(subject.files).to eq(jupyter.files) end context 'application failed to install previously' do @@ -64,19 +64,43 @@ describe Clusters::Applications::Jupyter do end end - describe '#values' do - let(:jupyter) { create(:clusters_applications_jupyter) } + describe '#files' do + let(:application) { create(:clusters_applications_jupyter) } + let(:values) { subject[:'values.yaml'] } - subject { jupyter.values } + subject { application.files } + + it 'should include cert files' do + expect(subject[:'ca.pem']).to be_present + expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert) + + expect(subject[:'cert.pem']).to be_present + expect(subject[:'key.pem']).to be_present + + cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem']) + expect(cert.not_after).to be < 60.minutes.from_now + end + + context 'when the helm application does not have a ca_cert' do + before do + application.cluster.application_helm.ca_cert = nil + end + + it 'should not include cert files' do + expect(subject[:'ca.pem']).not_to be_present + expect(subject[:'cert.pem']).not_to be_present + expect(subject[:'key.pem']).not_to be_present + end + end it 'should include valid values' do - is_expected.to include('ingress') - is_expected.to include('hub') - is_expected.to include('rbac') - is_expected.to include('proxy') - is_expected.to include('auth') - is_expected.to include("clientId: #{jupyter.oauth_application.uid}") - is_expected.to include("callbackUrl: #{jupyter.callback_url}") + expect(values).to include('ingress') + expect(values).to include('hub') + expect(values).to include('rbac') + expect(values).to include('proxy') + expect(values).to include('auth') + expect(values).to match(/clientId: '?#{application.oauth_application.uid}/) + expect(values).to match(/callbackUrl: '?#{application.callback_url}/) end end end diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index 3812c65b3b6..7454be3ab2f 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -167,7 +167,7 @@ describe Clusters::Applications::Prometheus do expect(command.name).to eq('prometheus') expect(command.chart).to eq('stable/prometheus') expect(command.version).to eq('6.7.3') - expect(command.values).to eq(prometheus.values) + expect(command.files).to eq(prometheus.files) end context 'application failed to install previously' do @@ -179,17 +179,41 @@ describe Clusters::Applications::Prometheus do end end - describe '#values' do - let(:prometheus) { create(:clusters_applications_prometheus) } + describe '#files' do + let(:application) { create(:clusters_applications_prometheus) } + let(:values) { subject[:'values.yaml'] } + + subject { application.files } + + it 'should include cert files' do + expect(subject[:'ca.pem']).to be_present + expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert) + + expect(subject[:'cert.pem']).to be_present + expect(subject[:'key.pem']).to be_present + + cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem']) + expect(cert.not_after).to be < 60.minutes.from_now + end - subject { prometheus.values } + context 'when the helm application does not have a ca_cert' do + before do + application.cluster.application_helm.ca_cert = nil + end + + it 'should not include cert files' do + expect(subject[:'ca.pem']).not_to be_present + expect(subject[:'cert.pem']).not_to be_present + expect(subject[:'key.pem']).not_to be_present + end + end it 'should include prometheus valid values' do - is_expected.to include('alertmanager') - is_expected.to include('kubeStateMetrics') - is_expected.to include('nodeExporter') - is_expected.to include('pushgateway') - is_expected.to include('serverFiles') + expect(values).to include('alertmanager') + expect(values).to include('kubeStateMetrics') + expect(values).to include('nodeExporter') + expect(values).to include('pushgateway') + expect(values).to include('serverFiles') end end end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 526300755b5..d84f125e246 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -47,7 +47,7 @@ describe Clusters::Applications::Runner do expect(subject.chart).to eq('runner/gitlab-runner') expect(subject.version).to eq('0.1.31') expect(subject.repository).to eq('https://charts.gitlab.io') - expect(subject.values).to eq(gitlab_runner.values) + expect(subject.files).to eq(gitlab_runner.files) end context 'application failed to install previously' do @@ -59,27 +59,51 @@ describe Clusters::Applications::Runner do end end - describe '#values' do - let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } + describe '#files' do + let(:application) { create(:clusters_applications_runner, runner: ci_runner) } + let(:values) { subject[:'values.yaml'] } + + subject { application.files } + + it 'should include cert files' do + expect(subject[:'ca.pem']).to be_present + expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert) + + expect(subject[:'cert.pem']).to be_present + expect(subject[:'key.pem']).to be_present + + cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem']) + expect(cert.not_after).to be < 60.minutes.from_now + end - subject { gitlab_runner.values } + context 'when the helm application does not have a ca_cert' do + before do + application.cluster.application_helm.ca_cert = nil + end + + it 'should not include cert files' do + expect(subject[:'ca.pem']).not_to be_present + expect(subject[:'cert.pem']).not_to be_present + expect(subject[:'key.pem']).not_to be_present + end + end it 'should include runner valid values' do - is_expected.to include('concurrent') - is_expected.to include('checkInterval') - is_expected.to include('rbac') - is_expected.to include('runners') - is_expected.to include('privileged: true') - is_expected.to include('image: ubuntu:16.04') - is_expected.to include('resources') - is_expected.to include("runnerToken: #{ci_runner.token}") - is_expected.to include("gitlabUrl: #{Gitlab::Routing.url_helpers.root_url}") + expect(values).to include('concurrent') + expect(values).to include('checkInterval') + expect(values).to include('rbac') + expect(values).to include('runners') + expect(values).to include('privileged: true') + expect(values).to include('image: ubuntu:16.04') + expect(values).to include('resources') + expect(values).to match(/runnerToken: '?#{ci_runner.token}/) + expect(values).to match(/gitlabUrl: '?#{Gitlab::Routing.url_helpers.root_url}/) end context 'without a runner' do let(:project) { create(:project) } - let(:cluster) { create(:cluster, projects: [project]) } - let(:gitlab_runner) { create(:clusters_applications_runner, cluster: cluster) } + let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) } + let(:application) { create(:clusters_applications_runner, cluster: cluster) } it 'creates a runner' do expect do @@ -88,18 +112,18 @@ describe Clusters::Applications::Runner do end it 'uses the new runner token' do - expect(subject).to include("runnerToken: #{gitlab_runner.reload.runner.token}") + expect(values).to match(/runnerToken: '?#{application.reload.runner.token}/) end it 'assigns the new runner to runner' do subject - expect(gitlab_runner.reload.runner).to be_project_type + expect(application.reload.runner).to be_project_type end end context 'with duplicated values on vendor/runner/values.yaml' do - let(:values) do + let(:stub_values) do { "concurrent" => 4, "checkInterval" => 3, @@ -118,11 +142,11 @@ describe Clusters::Applications::Runner do end before do - allow(gitlab_runner).to receive(:chart_values).and_return(values) + allow(application).to receive(:chart_values).and_return(stub_values) end it 'should overwrite values.yaml' do - is_expected.to include("privileged: #{gitlab_runner.privileged}") + expect(values).to match(/privileged: '?#{application.privileged}/) end end end diff --git a/spec/models/concerns/avatarable_spec.rb b/spec/models/concerns/avatarable_spec.rb index 9faf21bfbbd..76f734079b7 100644 --- a/spec/models/concerns/avatarable_spec.rb +++ b/spec/models/concerns/avatarable_spec.rb @@ -43,6 +43,10 @@ describe Avatarable do expect(project.avatar_path(only_path: only_path)).to eq(avatar_path) end + it 'returns the expected avatar path with width parameter' do + expect(project.avatar_path(only_path: only_path, size: 128)).to eq(avatar_path + "?width=128") + end + context "when avatar is stored remotely" do before do stub_uploads_object_storage(AvatarUploader) diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb index 79f75c0ffa0..97a4c212f1c 100644 --- a/spec/models/concerns/reactive_caching_spec.rb +++ b/spec/models/concerns/reactive_caching_spec.rb @@ -85,6 +85,14 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do it { is_expected.to be_nil } end + + context 'when cache was invalidated' do + it 'refreshes cache' do + expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666) + + instance.with_reactive_cache { raise described_class::InvalidateReactiveCache } + end + end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index ffdec09deef..6258bfa232f 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -311,6 +311,51 @@ describe MergeRequest do end end + describe '#visible_closing_issues_for' do + let(:guest) { create(:user) } + let(:developer) { create(:user) } + let(:issue_1) { create(:issue, project: subject.source_project) } + let(:issue_2) { create(:issue, project: subject.source_project) } + let(:confidential_issue) { create(:issue, :confidential, project: subject.source_project) } + + before do + subject.project.add_developer(subject.author) + subject.target_branch = subject.project.default_branch + commit = double('commit1', safe_message: "Fixes #{issue_1.to_reference} #{issue_2.to_reference} #{confidential_issue.to_reference}") + allow(subject).to receive(:commits).and_return([commit]) + end + + it 'shows only allowed issues to guest' do + subject.project.add_guest(guest) + + subject.cache_merge_request_closes_issues! + + expect(subject.visible_closing_issues_for(guest)).to match_array([issue_1, issue_2]) + end + + it 'shows only allowed issues to developer' do + subject.project.add_developer(developer) + + subject.cache_merge_request_closes_issues! + + expect(subject.visible_closing_issues_for(developer)).to match_array([issue_1, confidential_issue, issue_2]) + end + + context 'when external issue tracker is enabled' do + before do + subject.project.has_external_issue_tracker = true + subject.project.save! + end + + it 'calls non #closes_issues to retrieve data' do + expect(subject).to receive(:closes_issues) + expect(subject).not_to receive(:cached_closes_issues) + + subject.visible_closing_issues_for + end + end + end + describe '#cache_merge_request_closes_issues!' do before do subject.project.add_developer(subject.author) @@ -325,6 +370,25 @@ describe MergeRequest do expect { subject.cache_merge_request_closes_issues!(subject.author) }.to change(subject.merge_requests_closing_issues, :count).by(1) end + it 'does not cache closed issues when merge request is closed' do + issue = create :issue, project: subject.project + commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") + + allow(subject).to receive(:commits).and_return([commit]) + allow(subject).to receive(:state).and_return("closed") + + expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count) + end + + it 'does not cache closed issues when merge request is merged' do + issue = create :issue, project: subject.project + commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") + allow(subject).to receive(:commits).and_return([commit]) + allow(subject).to receive(:state).and_return("merged") + + expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count) + end + context 'when both internal and external issue trackers are enabled' do before do subject.project.has_external_issue_tracker = true @@ -633,6 +697,7 @@ describe MergeRequest do allow(subject).to receive(:commits).and_return([commit]) allow(subject.project).to receive(:default_branch) .and_return(subject.target_branch) + subject.cache_merge_request_closes_issues! expect(subject.issues_mentioned_but_not_closing(subject.author)).to match_array([mentioned_issue]) end @@ -650,6 +715,8 @@ describe MergeRequest do end it 'detects issues mentioned in description but not closed' do + subject.cache_merge_request_closes_issues! + expect(subject.issues_mentioned_but_not_closing(subject.author).map(&:to_s)).to match_array(['TEST-2']) end end @@ -780,9 +847,8 @@ describe MergeRequest do subject.project.add_developer(subject.author) subject.description = "This issue Closes #{issue.to_reference}" - - allow(subject.project).to receive(:default_branch) - .and_return(subject.target_branch) + allow(subject.project).to receive(:default_branch).and_return(subject.target_branch) + subject.cache_merge_request_closes_issues! expect(subject.merge_commit_message) .to match("Closes #{issue.to_reference}") @@ -1138,10 +1204,21 @@ describe MergeRequest do it 'returns status and data' do expect_any_instance_of(Ci::CompareTestReportsService) - .to receive(:execute).with(base_pipeline.iid, head_pipeline.iid) + .to receive(:execute).with(base_pipeline, head_pipeline).and_call_original subject end + + context 'when cached results is not latest' do + before do + allow_any_instance_of(Ci::CompareTestReportsService) + .to receive(:latest?).and_return(false) + end + + it 'raises and InvalidateReactiveCache error' do + expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache) + end + end end end @@ -1288,6 +1365,16 @@ describe MergeRequest do project.default_branch == branch) end + context 'but merged at timestamp cannot be found' do + before do + allow(subject).to receive(:merged_at) { nil } + end + + it 'returns false' do + expect(subject.can_be_reverted?(current_user)).to be_falsey + end + end + context 'when the revert commit is mentioned in a note after the MR was merged' do it 'returns false' do expect(subject.can_be_reverted?(current_user)).to be_falsey @@ -1327,6 +1414,63 @@ describe MergeRequest do end end + describe '#merged_at' do + context 'when MR is not merged' do + let(:merge_request) { create(:merge_request, :closed) } + + it 'returns nil' do + expect(merge_request.merged_at).to be_nil + end + end + + context 'when metrics has merged_at data' do + let(:merge_request) { create(:merge_request, :merged) } + + before do + merge_request.metrics.update!(merged_at: 1.day.ago) + end + + it 'returns metrics merged_at' do + expect(merge_request.merged_at).to eq(merge_request.metrics.merged_at) + end + end + + context 'when merged event is persisted, but no metrics merged_at is persisted' do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, :merged) } + + before do + EventCreateService.new.merge_mr(merge_request, user) + end + + it 'returns merged event creation date' do + expect(merge_request.merge_event).to be_persisted + expect(merge_request.merged_at).to eq(merge_request.merge_event.created_at) + end + end + + context 'when merging note is persisted, but no metrics or merge event exists' do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, :merged) } + + before do + merge_request.metrics.destroy! + + SystemNoteService.change_status(merge_request, + merge_request.target_project, + user, + merge_request.state, nil) + end + + it 'returns merging note creation date' do + expect(merge_request.reload.metrics).to be_nil + expect(merge_request.merge_event).to be_nil + expect(merge_request.notes.count).to eq(1) + expect(merge_request.merged_at).to eq(merge_request.notes.first.created_at) + end + end + end + describe '#participants' do let(:project) { create(:project, :public) } diff --git a/spec/models/postgresql/replication_slot_spec.rb b/spec/models/postgresql/replication_slot_spec.rb new file mode 100644 index 00000000000..919a7526803 --- /dev/null +++ b/spec/models/postgresql/replication_slot_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Postgresql::ReplicationSlot, :postgresql do + describe '.lag_too_great?' do + it 'returns true when replication lag is too great' do + expect(described_class) + .to receive(:pluck) + .and_return([125.megabytes]) + + expect(described_class.lag_too_great?).to eq(true) + end + + it 'returns false when more than one replicas is up to date enough' do + expect(described_class) + .to receive(:pluck) + .and_return([125.megabytes, 0.megabytes, 0.megabytes]) + + expect(described_class.lag_too_great?).to eq(false) + end + + it 'returns false when replication lag is not too great' do + expect(described_class) + .to receive(:pluck) + .and_return([0.megabytes]) + + expect(described_class.lag_too_great?).to eq(false) + end + end +end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 6c637533c6b..ac9ff59b9b5 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -30,6 +30,10 @@ describe JiraService do describe "Associations" do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } + it { is_expected.to allow_value(nil).for(:jira_issue_transition_id) } + it { is_expected.to allow_value("1,2,3").for(:jira_issue_transition_id) } + it { is_expected.to allow_value("1;2;3").for(:jira_issue_transition_id) } + it { is_expected.not_to allow_value("a,b,cd").for(:jira_issue_transition_id) } end describe 'Validations' do @@ -124,7 +128,7 @@ describe JiraService do url: 'http://jira.example.com', username: 'gitlab_jira_username', password: 'gitlab_jira_password', - jira_issue_transition_id: "custom-id" + jira_issue_transition_id: "999" ) # These stubs are needed to test JiraService#close_issue. @@ -226,12 +230,49 @@ describe JiraService do ).once end - it "calls the api with jira_issue_transition_id" do - @jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project)) + context '#close_issue' do + it "logs exception when transition id is not valid" do + allow(Rails.logger).to receive(:info) + WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)).and_raise("Bad Request") - expect(WebMock).to have_requested(:post, @transitions_url).with( - body: /custom-id/ - ).once + @jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project)) + + expect(Rails.logger).to have_received(:info).with("JiraService Issue Transition failed message ERROR: http://jira.example.com - Bad Request") + end + + it "calls the api with jira_issue_transition_id" do + @jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project)) + + expect(WebMock).to have_requested(:post, @transitions_url).with( + body: /999/ + ).once + end + + context "when have multiple transition ids" do + it "calls the api with transition ids separated by comma" do + allow(@jira_service).to receive_messages(jira_issue_transition_id: "1,2,3") + + @jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project)) + + 1.upto(3) do |transition_id| + expect(WebMock).to have_requested(:post, @transitions_url).with( + body: /#{transition_id}/ + ).once + end + end + + it "calls the api with transition ids separated by semicolon" do + allow(@jira_service).to receive_messages(jira_issue_transition_id: "1;2;3") + + @jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project)) + + 1.upto(3) do |transition_id| + expect(WebMock).to have_requested(:post, @transitions_url).with( + body: /#{transition_id}/ + ).once + end + end + end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4313d52d60a..03beb9187ed 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3307,6 +3307,50 @@ describe Project do end end + describe '#has_auto_devops_implicitly_enabled?' do + set(:project) { create(:project) } + + context 'when disabled in settings' do + before do + stub_application_setting(auto_devops_enabled: false) + end + + it 'does not have auto devops implicitly disabled' do + expect(project).not_to have_auto_devops_implicitly_enabled + end + end + + context 'when enabled in settings' do + before do + stub_application_setting(auto_devops_enabled: true) + end + + it 'auto devops is implicitly disabled' do + expect(project).to have_auto_devops_implicitly_enabled + end + + context 'when explicitly disabled' do + before do + create(:project_auto_devops, project: project, enabled: false) + end + + it 'does not have auto devops implicitly disabled' do + expect(project).not_to have_auto_devops_implicitly_enabled + end + end + + context 'when explicitly enabled' do + before do + create(:project_auto_devops, project: project, enabled: true) + end + + it 'does not have auto devops implicitly disabled' do + expect(project).not_to have_auto_devops_implicitly_enabled + end + end + end + end + describe '#has_auto_devops_implicitly_disabled?' do set(:project) { create(:project) } @@ -3341,7 +3385,7 @@ describe Project do context 'when explicitly enabled' do before do - create(:project_auto_devops, project: project) + create(:project_auto_devops, project: project, enabled: true) end it 'does not have auto devops implicitly disabled' do diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index bd498269798..f29abcf536e 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -7,6 +7,7 @@ describe Todo do it { is_expected.to belong_to(:author).class_name("User") } it { is_expected.to belong_to(:note) } it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:target).touch(true) } it { is_expected.to belong_to(:user) } end diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index 46ba6f442f5..a1b52d8692d 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -117,9 +117,9 @@ describe MergeRequestPresenter do before do project.add_developer(user) - allow(resource.project).to receive(:default_branch) .and_return(resource.target_branch) + resource.cache_merge_request_closes_issues! end describe '#closing_issues_links' do diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index b537b6e1667..85c93f35c20 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -429,7 +429,7 @@ describe API::Internal do context "archived project" do before do project.add_developer(user) - project.archive! + ::Projects::UpdateService.new(project, user, archived: true).execute end context "git pull" do diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 1716d182782..4de834bf93a 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -954,6 +954,7 @@ describe API::MergeRequests do issue = create(:issue, project: project) mr = merge_request.tap do |mr| mr.update_attribute(:description, "Closes #{issue.to_reference(mr.project)}") + mr.cache_merge_request_closes_issues! end get api("/projects/#{project.id}/merge_requests/#{mr.iid}/closes_issues", user) diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 71e3436fa76..eb41750bf47 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1677,7 +1677,7 @@ describe API::Projects do context 'on an archived project' do before do - project.archive! + ::Projects::UpdateService.new(project, user, archived: true).execute end it 'remains archived' do @@ -1713,7 +1713,7 @@ describe API::Projects do context 'on an archived project' do before do - project.archive! + ::Projects::UpdateService.new(project, user, archived: true).execute end it 'unarchives the project' do diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index 2ee8d150dc8..b5cf04e7f22 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe API::Todos do - let(:project_1) { create(:project, :repository) } + let(:group) { create(:group) } + let(:project_1) { create(:project, :repository, group: group) } let(:project_2) { create(:project) } let(:author_1) { create(:user) } let(:author_2) { create(:user) } @@ -92,6 +93,17 @@ describe API::Todos do end end + context 'and using the group filter' do + it 'filters based on project_id param' do + get api('/todos', john_doe), { group_id: group.id, sort: :target_id } + + expect(response.status).to eq(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end + end + context 'and using the action filter' do it 'filters based on action param' do get api('/todos', john_doe), { action: 'mentioned' } diff --git a/spec/services/ci/compare_test_reports_service_spec.rb b/spec/services/ci/compare_test_reports_service_spec.rb index d3bbf17cc5c..a26c970a8f0 100644 --- a/spec/services/ci/compare_test_reports_service_spec.rb +++ b/spec/services/ci/compare_test_reports_service_spec.rb @@ -5,7 +5,7 @@ describe Ci::CompareTestReportsService do let(:project) { create(:project, :repository) } describe '#execute' do - subject { service.execute(base_pipeline&.iid, head_pipeline.iid) } + subject { service.execute(base_pipeline, head_pipeline) } context 'when head pipeline has test reports' do let!(:base_pipeline) { nil } @@ -42,4 +42,34 @@ describe Ci::CompareTestReportsService do end end end + + describe '#latest?' do + subject { service.latest?(base_pipeline, head_pipeline, data) } + + let!(:base_pipeline) { nil } + let!(:head_pipeline) { create(:ci_pipeline, :with_test_reports, project: project) } + let!(:key) { service.send(:key, base_pipeline, head_pipeline) } + + context 'when cache key is latest' do + let(:data) { { key: key } } + + it { is_expected.to be_truthy } + end + + context 'when cache key is outdated' do + before do + head_pipeline.update_column(:updated_at, 10.minutes.ago) + end + + let(:data) { { key: key } } + + it { is_expected.to be_falsy } + end + + context 'when cache key is empty' do + let(:data) { { key: nil } } + + it { is_expected.to be_falsy } + end + end end diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb index 93199964a0e..a744ec30b65 100644 --- a/spec/services/clusters/applications/install_service_spec.rb +++ b/spec/services/clusters/applications/install_service_spec.rb @@ -47,7 +47,7 @@ describe Clusters::Applications::InstallService do end context 'when application cannot be persisted' do - let(:application) { build(:clusters_applications_helm, :scheduled) } + let(:application) { create(:clusters_applications_helm, :scheduled) } it 'make the application errored' do expect(application).to receive(:make_installing!).once.and_raise(ActiveRecord::RecordInvalid) diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index 48d689e11d4..7c5c7409cc1 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -12,13 +12,17 @@ describe Groups::UpdateService do let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } before do - public_group.add_user(user, Gitlab::Access::MAINTAINER) + public_group.add_user(user, Gitlab::Access::OWNER) create(:project, :public, group: public_group) + + expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in) end it "does not change permission level" do service.execute expect(public_group.errors.count).to eq(1) + + expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in) end end @@ -26,8 +30,10 @@ describe Groups::UpdateService do let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } before do - internal_group.add_user(user, Gitlab::Access::MAINTAINER) + internal_group.add_user(user, Gitlab::Access::OWNER) create(:project, :internal, group: internal_group) + + expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in) end it "does not change permission level" do @@ -35,6 +41,24 @@ describe Groups::UpdateService do expect(internal_group.errors.count).to eq(1) end end + + context "internal group with private project" do + let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + + before do + internal_group.add_user(user, Gitlab::Access::OWNER) + create(:project, :private, group: internal_group) + + expect(TodosDestroyer::GroupPrivateWorker).to receive(:perform_in) + .with(1.hour, internal_group.id) + end + + it "changes permission level to private" do + service.execute + expect(internal_group.visibility_level) + .to eq(Gitlab::VisibilityLevel::PRIVATE) + end + end end context "with parent_id user doesn't have permissions for" do diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb index 3b617d4ca54..2a56075419b 100644 --- a/spec/services/issues/reopen_service_spec.rb +++ b/spec/services/issues/reopen_service_spec.rb @@ -20,7 +20,7 @@ describe Issues::ReopenService do end end - context 'when user is authrized to reopen issue' do + context 'when user is authorized to reopen issue' do let(:user) { create(:user) } before do diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 9dd235f6660..5d96b5ce27c 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -49,6 +49,7 @@ describe MergeRequests::MergeService do issue = create :issue, project: project commit = double('commit', safe_message: "Fixes #{issue.to_reference}") allow(merge_request).to receive(:commits).and_return([commit]) + merge_request.cache_merge_request_closes_issues! service.execute(merge_request) diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index ba2b062875b..5ad6f5528f9 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -53,7 +53,7 @@ describe MergeRequests::PostMergeService do allow(project).to receive(:default_branch).and_return('foo') issue = create(:issue, project: project) - allow(merge_request).to receive(:closes_issues).and_return([issue]) + allow(merge_request).to receive(:visible_closing_issues_for).and_return([issue]) allow_any_instance_of(Issues::CloseService).to receive(:execute).with(issue, commit: merge_request).and_raise expect { described_class.new(project, user, {}).execute(merge_request) }.to raise_error diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index e10eaa95da4..21e71509ed6 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -47,6 +47,12 @@ describe MergeRequests::ReopenService do end end + it 'caches merge request closing issues' do + expect(merge_request).to receive(:cache_merge_request_closes_issues!) + + described_class.new(project, user, {}).execute(merge_request) + end + it 'updates metrics' do metrics = merge_request.metrics service = double(MergeRequestMetricsService) diff --git a/spec/services/milestones/update_service_spec.rb b/spec/services/milestones/update_service_spec.rb new file mode 100644 index 00000000000..3b91442c0ba --- /dev/null +++ b/spec/services/milestones/update_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Milestones::UpdateService do + let(:project) { create(:project) } + let(:user) { build(:user) } + let(:milestone) { create(:milestone, project: project) } + + describe '#execute' do + context "valid params" do + let(:inner_service) { double(:service) } + + before do + project.add_maintainer(user) + end + + subject { described_class.new(project, user, { title: 'new_title' }).execute(milestone) } + + it { expect(subject).to be_valid } + it { expect(subject.title).to eq('new_title') } + + context 'state_event is activate' do + it 'calls ReopenService' do + expect(Milestones::ReopenService).to receive(:new).with(project, user, {}).and_return(inner_service) + expect(inner_service).to receive(:execute).with(milestone) + + described_class.new(project, user, { state_event: 'activate' }).execute(milestone) + end + end + + context 'state_event is close' do + it 'calls ReopenService' do + expect(Milestones::CloseService).to receive(:new).with(project, user, {}).and_return(inner_service) + expect(inner_service).to receive(:execute).with(milestone) + + described_class.new(project, user, { state_event: 'close' }).execute(milestone) + end + end + end + end +end diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 0fd37c95e42..b1290fd0d47 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -145,7 +145,9 @@ describe Notes::CreateService do let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } it 'saves the note and does not alter the note text' do - expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original + service = double(:service) + allow(Issues::UpdateService).to receive(:new).and_return(service) + expect(service).to receive(:execute) note = described_class.new(project, user, opts.merge(note: note_text)).execute diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index fd69fe04053..bb3f1501f0e 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -114,6 +114,17 @@ describe Projects::CreateService, '#execute' do end end + context 'import data' do + it 'stores import data and URL' do + import_data = { data: { 'test' => 'some data' } } + project = create_project(user, { name: 'test', import_url: 'http://import-url', import_data: import_data }) + + expect(project.import_data).to be_persisted + expect(project.import_data.data).to eq(import_data[:data]) + expect(project.import_url).to eq('http://import-url') + end + end + context 'builds_enabled global setting' do let(:project) { create_project(user, opts) } diff --git a/spec/services/todos/destroy/confidential_issue_service_spec.rb b/spec/services/todos/destroy/confidential_issue_service_spec.rb index 54d1d7e83f1..3294f7509aa 100644 --- a/spec/services/todos/destroy/confidential_issue_service_spec.rb +++ b/spec/services/todos/destroy/confidential_issue_service_spec.rb @@ -29,12 +29,8 @@ describe Todos::Destroy::ConfidentialIssueService do issue.update!(confidential: true) end - it 'removes issue todos for a user who is not a project member' do + it 'removes issue todos for users who can not access the confidential issue' do expect { subject }.to change { Todo.count }.from(6).to(4) - - expect(user.todos).to match_array([todo_another_non_member]) - expect(author.todos).to match_array([todo_issue_author]) - expect(project_member.todos).to match_array([todo_issue_member]) end end diff --git a/spec/services/todos/destroy/entity_leave_service_spec.rb b/spec/services/todos/destroy/entity_leave_service_spec.rb index bad408a314e..8cb91e7c1b9 100644 --- a/spec/services/todos/destroy/entity_leave_service_spec.rb +++ b/spec/services/todos/destroy/entity_leave_service_spec.rb @@ -5,60 +5,120 @@ describe Todos::Destroy::EntityLeaveService do let(:project) { create(:project, group: group) } let(:user) { create(:user) } let(:user2) { create(:user) } - let(:issue) { create(:issue, project: project) } + let(:issue) { create(:issue, project: project, confidential: true) } let(:mr) { create(:merge_request, source_project: project) } let!(:todo_mr_user) { create(:todo, user: user, target: mr, project: project) } let!(:todo_issue_user) { create(:todo, user: user, target: issue, project: project) } + let!(:todo_group_user) { create(:todo, user: user, group: group) } let!(:todo_issue_user2) { create(:todo, user: user2, target: issue, project: project) } + let!(:todo_group_user2) { create(:todo, user: user2, group: group) } describe '#execute' do context 'when a user leaves a project' do subject { described_class.new(user.id, project.id, 'Project').execute } context 'when project is private' do - it 'removes todos for the provided user' do - expect { subject }.to change { Todo.count }.from(3).to(1) + it 'removes project todos for the provided user' do + expect { subject }.to change { Todo.count }.from(5).to(3) - expect(user.todos).to be_empty - expect(user2.todos).to match_array([todo_issue_user2]) + expect(user.todos).to match_array([todo_group_user]) + expect(user2.todos).to match_array([todo_issue_user2, todo_group_user2]) end - end - context 'when project is not private' do - before do - group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) - project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + context 'when the user is member of the project' do + before do + project.add_developer(user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end end - context 'when a user is not an author of confidential issue' do + context 'when the user is a project guest' do before do - issue.update!(confidential: true) + project.add_guest(user) end it 'removes only confidential issues todos' do - expect { subject }.to change { Todo.count }.from(3).to(2) + expect { subject }.to change { Todo.count }.from(5).to(4) end end - context 'when a user is an author of confidential issue' do + context 'when the user is member of a parent group' do before do - issue.update!(author: user, confidential: true) + group.add_developer(user) end - it 'removes only confidential issues todos' do + it 'does not remove any todos' do expect { subject }.not_to change { Todo.count } end end - context 'when a user is an assignee of confidential issue' do + context 'when the user is guest of a parent group' do before do - issue.update!(confidential: true) - issue.assignees << user + project.add_guest(user) end it 'removes only confidential issues todos' do - expect { subject }.not_to change { Todo.count } + expect { subject }.to change { Todo.count }.from(5).to(4) + end + end + end + + context 'when project is not private' do + before do + group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + context 'confidential issues' do + context 'when a user is not an author of confidential issue' do + it 'removes only confidential issues todos' do + expect { subject }.to change { Todo.count }.from(5).to(4) + end + end + + context 'when a user is an author of confidential issue' do + before do + issue.update!(author: user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end + end + + context 'when a user is an assignee of confidential issue' do + before do + issue.assignees << user + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end + end + + context 'when a user is a project guest' do + before do + project.add_guest(user) + end + + it 'removes only confidential issues todos' do + expect { subject }.to change { Todo.count }.from(5).to(4) + end + end + + context 'when a user is a project guest but group developer' do + before do + project.add_guest(user) + group.add_developer(user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end end end @@ -69,7 +129,7 @@ describe Todos::Destroy::EntityLeaveService do end it 'removes only users issue todos' do - expect { subject }.to change { Todo.count }.from(3).to(2) + expect { subject }.to change { Todo.count }.from(5).to(4) end end end @@ -80,40 +140,135 @@ describe Todos::Destroy::EntityLeaveService do subject { described_class.new(user.id, group.id, 'Group').execute } context 'when group is private' do - it 'removes todos for the user' do - expect { subject }.to change { Todo.count }.from(3).to(1) + it 'removes group and subproject todos for the user' do + expect { subject }.to change { Todo.count }.from(5).to(2) expect(user.todos).to be_empty - expect(user2.todos).to match_array([todo_issue_user2]) + expect(user2.todos).to match_array([todo_issue_user2, todo_group_user2]) + end + + context 'when the user is member of the group' do + before do + group.add_developer(user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end + end + + context 'when the user is member of the group project but not the group' do + before do + project.add_developer(user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end end context 'with nested groups', :nested_groups do let(:subgroup) { create(:group, :private, parent: group) } + let(:subgroup2) { create(:group, :private, parent: group) } let(:subproject) { create(:project, group: subgroup) } + let(:subproject2) { create(:project, group: subgroup2) } - let!(:todo_subproject_user) { create(:todo, user: user, project: subproject) } + let!(:todo_subproject_user) { create(:todo, user: user, project: subproject) } + let!(:todo_subproject2_user) { create(:todo, user: user, project: subproject2) } + let!(:todo_subgroup_user) { create(:todo, user: user, group: subgroup) } + let!(:todo_subgroup2_user) { create(:todo, user: user, group: subgroup2) } let!(:todo_subproject_user2) { create(:todo, user: user2, project: subproject) } + let!(:todo_subpgroup_user2) { create(:todo, user: user2, group: subgroup) } + + context 'when the user is not a member of any groups/projects' do + it 'removes todos for the user including subprojects todos' do + expect { subject }.to change { Todo.count }.from(11).to(4) + + expect(user.todos).to be_empty + expect(user2.todos) + .to match_array( + [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2] + ) + end + end + + context 'when the user is member of a parent group' do + before do + parent_group = create(:group) + group.update!(parent: parent_group) + parent_group.add_developer(user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end + end + + context 'when the user is member of a subgroup' do + before do + subgroup.add_developer(user) + end - it 'removes todos for the user including subprojects todos' do - expect { subject }.to change { Todo.count }.from(5).to(2) + it 'does not remove group and subproject todos' do + expect { subject }.to change { Todo.count }.from(11).to(7) - expect(user.todos).to be_empty - expect(user2.todos) - .to match_array([todo_issue_user2, todo_subproject_user2]) + expect(user.todos).to match_array([todo_group_user, todo_subgroup_user, todo_subproject_user]) + expect(user2.todos) + .to match_array( + [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2] + ) + end + end + + context 'when the user is member of a child project' do + before do + subproject.add_developer(user) + end + + it 'does not remove subproject and group todos' do + expect { subject }.to change { Todo.count }.from(11).to(7) + + expect(user.todos).to match_array([todo_subgroup_user, todo_group_user, todo_subproject_user]) + expect(user2.todos) + .to match_array( + [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2] + ) + end end end end context 'when group is not private' do before do - issue.update!(confidential: true) - group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) end - it 'removes only confidential issues todos' do - expect { subject }.to change { Todo.count }.from(3).to(2) + context 'when user is not member' do + it 'removes only confidential issues todos' do + expect { subject }.to change { Todo.count }.from(5).to(4) + end + end + + context 'when user is a project guest' do + before do + project.add_guest(user) + end + + it 'removes only confidential issues todos' do + expect { subject }.to change { Todo.count }.from(5).to(4) + end + end + + context 'when user is a project guest & group developer' do + before do + project.add_guest(user) + group.add_developer(user) + end + + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end end end end diff --git a/spec/services/todos/destroy/group_private_service_spec.rb b/spec/services/todos/destroy/group_private_service_spec.rb new file mode 100644 index 00000000000..2f49b68f544 --- /dev/null +++ b/spec/services/todos/destroy/group_private_service_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe Todos::Destroy::GroupPrivateService do + let(:group) { create(:group, :public) } + let(:project) { create(:project, group: group) } + let(:user) { create(:user) } + let(:group_member) { create(:user) } + let(:project_member) { create(:user) } + + let!(:todo_non_member) { create(:todo, user: user, group: group) } + let!(:todo_another_non_member) { create(:todo, user: user, group: group) } + let!(:todo_group_member) { create(:todo, user: group_member, group: group) } + let!(:todo_project_member) { create(:todo, user: project_member, group: group) } + + describe '#execute' do + before do + group.add_developer(group_member) + project.add_developer(project_member) + end + + subject { described_class.new(group.id).execute } + + context 'when a group set to private' do + before do + group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it 'removes todos only for users who are not group users' do + expect { subject }.to change { Todo.count }.from(4).to(2) + + expect(user.todos).to be_empty + expect(group_member.todos).to match_array([todo_group_member]) + expect(project_member.todos).to match_array([todo_project_member]) + end + + context 'with nested groups', :nested_groups do + let(:parent_group) { create(:group) } + let(:subgroup) { create(:group, :private, parent: group) } + let(:subproject) { create(:project, group: subgroup) } + + let(:parent_member) { create(:user) } + let(:subgroup_member) { create(:user) } + let(:subgproject_member) { create(:user) } + + let!(:todo_parent_member) { create(:todo, user: parent_member, group: group) } + let!(:todo_subgroup_member) { create(:todo, user: subgroup_member, group: group) } + let!(:todo_subproject_member) { create(:todo, user: subgproject_member, group: group) } + + before do + group.update!(parent: parent_group) + + parent_group.add_developer(parent_member) + subgroup.add_developer(subgroup_member) + subproject.add_developer(subgproject_member) + end + + it 'removes todos only for users who are not group users' do + expect { subject }.to change { Todo.count }.from(7).to(5) + end + end + end + + context 'when group is not private' do + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count } + end + end + end +end diff --git a/spec/services/todos/destroy/project_private_service_spec.rb b/spec/services/todos/destroy/project_private_service_spec.rb index badf3f913a5..128d3487514 100644 --- a/spec/services/todos/destroy/project_private_service_spec.rb +++ b/spec/services/todos/destroy/project_private_service_spec.rb @@ -1,17 +1,21 @@ require 'spec_helper' describe Todos::Destroy::ProjectPrivateService do - let(:project) { create(:project, :public) } + let(:group) { create(:group, :public) } + let(:project) { create(:project, :public, group: group) } let(:user) { create(:user) } let(:project_member) { create(:user) } + let(:group_member) { create(:user) } - let!(:todo_issue_non_member) { create(:todo, user: user, project: project) } - let!(:todo_issue_member) { create(:todo, user: project_member, project: project) } - let!(:todo_another_non_member) { create(:todo, user: user, project: project) } + let!(:todo_non_member) { create(:todo, user: user, project: project) } + let!(:todo2_non_member) { create(:todo, user: user, project: project) } + let!(:todo_member) { create(:todo, user: project_member, project: project) } + let!(:todo_group_member) { create(:todo, user: group_member, project: project) } describe '#execute' do before do project.add_developer(project_member) + group.add_developer(group_member) end subject { described_class.new(project.id).execute } @@ -22,10 +26,11 @@ describe Todos::Destroy::ProjectPrivateService do end it 'removes issue todos for a user who is not a member' do - expect { subject }.to change { Todo.count }.from(3).to(1) + expect { subject }.to change { Todo.count }.from(4).to(2) expect(user.todos).to be_empty - expect(project_member.todos).to match_array([todo_issue_member]) + expect(project_member.todos).to match_array([todo_member]) + expect(group_member.todos).to match_array([todo_group_member]) end end diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 8e1d4cfe269..f392660d2c7 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -8,7 +8,7 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { - 'signed-commits' => '2d1096e', + 'signed-commits' => '6101e87', 'not-merged-branch' => 'b83d6e3', 'branch-merged' => '498214d', 'empty-branch' => '7efb185', @@ -51,7 +51,8 @@ module TestEnv 'add-pdf-text-binary' => '79faa7b', 'add_images_and_changes' => '010d106', 'update-gitlab-shell-v-6-0-1' => '2f61d70', - 'update-gitlab-shell-v-6-0-3' => 'de78448' + 'update-gitlab-shell-v-6-0-3' => 'de78448', + '2-mb-file' => 'bf12d25' }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/support/shared_examples/controllers/todos_shared_examples.rb b/spec/support/shared_examples/controllers/todos_shared_examples.rb new file mode 100644 index 00000000000..bafd9bac8d0 --- /dev/null +++ b/spec/support/shared_examples/controllers/todos_shared_examples.rb @@ -0,0 +1,43 @@ +shared_examples 'todos actions' do + context 'when authorized' do + before do + sign_in(user) + parent.add_developer(user) + end + + it 'creates todo' do + expect do + post_create + end.to change { user.todos.count }.by(1) + + expect(response).to have_gitlab_http_status(200) + end + + it 'returns todo path and pending count' do + post_create + + expect(response).to have_gitlab_http_status(200) + expect(json_response['count']).to eq 1 + expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}}) + end + end + + context 'when not authorized for project/group' do + it 'does not create todo for resource that user has no access to' do + sign_in(user) + expect do + post_create + end.to change { user.todos.count }.by(0) + + expect(response).to have_gitlab_http_status(404) + end + + it 'does not create todo when user is not logged in' do + expect do + post_create + end.to change { user.todos.count }.by(0) + + expect(response).to have_gitlab_http_status(parent.is_a?(Group) ? 401 : 302) + end + end +end diff --git a/spec/support/shared_examples/services/boards/issues_move_service.rb b/spec/support/shared_examples/services/boards/issues_move_service.rb index 737863ea411..6d29a97c56d 100644 --- a/spec/support/shared_examples/services/boards/issues_move_service.rb +++ b/spec/support/shared_examples/services/boards/issues_move_service.rb @@ -4,7 +4,9 @@ shared_examples 'issues move service' do |group| let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } } it 'delegates the label changes to Issues::UpdateService' do - expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once + service = double(:service) + expect(Issues::UpdateService).to receive(:new).and_return(service) + expect(service).to receive(:execute).with(issue).once described_class.new(parent, user, params).execute(issue) end diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb index d67e7698635..3bd072e7125 100644 --- a/spec/workers/background_migration_worker_spec.rb +++ b/spec/workers/background_migration_worker_spec.rb @@ -3,6 +3,12 @@ require 'spec_helper' describe BackgroundMigrationWorker, :sidekiq, :clean_gitlab_redis_shared_state do let(:worker) { described_class.new } + describe '.minimum_interval' do + it 'returns 2 minutes' do + expect(described_class.minimum_interval).to eq(2.minutes.to_i) + end + end + describe '.perform' do it 'performs a background migration' do expect(Gitlab::BackgroundMigration) @@ -28,5 +34,51 @@ describe BackgroundMigrationWorker, :sidekiq, :clean_gitlab_redis_shared_state d worker.perform('Foo', [10, 20]) end + + it 'reschedules a migration if the database is not healthy' do + allow(worker) + .to receive(:always_perform?) + .and_return(false) + + allow(worker) + .to receive(:healthy_database?) + .and_return(false) + + expect(described_class) + .to receive(:perform_in) + .with(a_kind_of(Numeric), 'Foo', [10, 20]) + + worker.perform('Foo', [10, 20]) + end + end + + describe '#healthy_database?' do + context 'using MySQL', :mysql do + it 'returns true' do + expect(worker.healthy_database?).to eq(true) + end + end + + context 'using PostgreSQL', :postgresql do + context 'when replication lag is too great' do + it 'returns false' do + allow(Postgresql::ReplicationSlot) + .to receive(:lag_too_great?) + .and_return(true) + + expect(worker.healthy_database?).to eq(false) + end + end + + context 'when replication lag is small enough' do + it 'returns true' do + allow(Postgresql::ReplicationSlot) + .to receive(:lag_too_great?) + .and_return(false) + + expect(worker.healthy_database?).to eq(true) + end + end + end end end diff --git a/spec/workers/todos_destroyer/group_private_worker_spec.rb b/spec/workers/todos_destroyer/group_private_worker_spec.rb new file mode 100644 index 00000000000..fcc38989ced --- /dev/null +++ b/spec/workers/todos_destroyer/group_private_worker_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe TodosDestroyer::GroupPrivateWorker do + it "calls the Todos::Destroy::GroupPrivateService with the params it was given" do + service = double + + expect(::Todos::Destroy::GroupPrivateService).to receive(:new).with(100).and_return(service) + expect(service).to receive(:execute) + + described_class.new.perform(100) + end +end diff --git a/vendor/Dockerfile/Node-alpine.Dockerfile b/vendor/Dockerfile/Node-alpine.Dockerfile index 9776b1336b5..5b9b495644a 100644 --- a/vendor/Dockerfile/Node-alpine.Dockerfile +++ b/vendor/Dockerfile/Node-alpine.Dockerfile @@ -1,14 +1,15 @@ -FROM node:7.9-alpine +FROM node:8.11-alpine WORKDIR /usr/src/app ARG NODE_ENV ENV NODE_ENV $NODE_ENV + COPY package.json /usr/src/app/ -RUN npm install && npm cache clean -COPY . /usr/src/app +RUN npm install -CMD [ "npm", "start" ] +COPY . /usr/src/app # replace this with your application's default port EXPOSE 8888 +CMD [ "npm", "start" ] diff --git a/vendor/Dockerfile/Node.Dockerfile b/vendor/Dockerfile/Node.Dockerfile index 7e936d5e887..e8b64b3a6e4 100644 --- a/vendor/Dockerfile/Node.Dockerfile +++ b/vendor/Dockerfile/Node.Dockerfile @@ -1,14 +1,15 @@ -FROM node:7.9 +FROM node:8.11 WORKDIR /usr/src/app ARG NODE_ENV ENV NODE_ENV $NODE_ENV + COPY package.json /usr/src/app/ -RUN npm install && npm cache clean -COPY . /usr/src/app +RUN npm install -CMD [ "npm", "start" ] +COPY . /usr/src/app # replace this with your application's default port EXPOSE 8888 +CMD [ "npm", "start" ]
\ No newline at end of file diff --git a/vendor/Dockerfile/Ruby-alpine.Dockerfile b/vendor/Dockerfile/Ruby-alpine.Dockerfile index 9db4e2130f2..dffe9a65116 100644 --- a/vendor/Dockerfile/Ruby-alpine.Dockerfile +++ b/vendor/Dockerfile/Ruby-alpine.Dockerfile @@ -1,8 +1,8 @@ -FROM ruby:2.4-alpine +FROM ruby:2.5-alpine # Edit with nodejs, mysql-client, postgresql-client, sqlite3, etc. for your needs. # Or delete entirely if not needed. -RUN apk --no-cache add nodejs postgresql-client +RUN apk --no-cache add nodejs postgresql-client tzdata # throw errors if Gemfile has been modified since Gemfile.lock RUN bundle config --global frozen 1 @@ -11,7 +11,10 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY Gemfile Gemfile.lock /usr/src/app/ -RUN bundle install +# Install build dependencies - required for gems with native dependencies +RUN apk add --no-cache --virtual build-deps build-base postgresql-dev && \ + bundle install && \ + apk del build-deps COPY . /usr/src/app @@ -21,4 +24,4 @@ COPY . /usr/src/app # For Rails EXPOSE 3000 -CMD ["rails", "server"] +CMD ["bundle", "exec", "rails", "server"] diff --git a/vendor/Dockerfile/Ruby.Dockerfile b/vendor/Dockerfile/Ruby.Dockerfile index feb880ee4b2..289ed57bfa2 100644 --- a/vendor/Dockerfile/Ruby.Dockerfile +++ b/vendor/Dockerfile/Ruby.Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.4 +FROM ruby:2.5 # Edit with nodejs, mysql-client, postgresql-client, sqlite3, etc. for your needs. # Or delete entirely if not needed. @@ -24,4 +24,4 @@ COPY . /usr/src/app # For Rails EXPOSE 3000 -CMD ["rails", "server", "-b", "0.0.0.0"] +CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] diff --git a/vendor/gitignore/Autotools.gitignore b/vendor/gitignore/Autotools.gitignore index 96d6ed2cfea..f4f545c9ca4 100644 --- a/vendor/gitignore/Autotools.gitignore +++ b/vendor/gitignore/Autotools.gitignore @@ -16,6 +16,8 @@ autom4te.cache /compile /config.guess /config.h.in +/config.log +/config.status /config.sub /configure /configure.scan diff --git a/vendor/gitignore/Laravel.gitignore b/vendor/gitignore/Laravel.gitignore index a4854bef534..67e2146f2bc 100644 --- a/vendor/gitignore/Laravel.gitignore +++ b/vendor/gitignore/Laravel.gitignore @@ -1,6 +1,7 @@ vendor/ node_modules/ npm-debug.log +yarn-error.log # Laravel 4 specific bootstrap/compiled.php @@ -10,11 +11,7 @@ app/storage/ public/storage public/hot storage/*.key -.env.*.php -.env.php .env Homestead.yaml Homestead.json - -# Rocketeer PHP task runner and deployment package. https://github.com/rocketeers/rocketeer -.rocketeer/ +/.vagrant diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore index f431ddc7cf5..94b41b913fb 100644 --- a/vendor/gitignore/VisualStudio.gitignore +++ b/vendor/gitignore/VisualStudio.gitignore @@ -59,7 +59,7 @@ StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c -*_i.h +*_h.h *.ilk *.meta *.obj @@ -327,3 +327,6 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ + +# Local History for Visual Studio +.localhistory/ diff --git a/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml b/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml index d5ee7ed2c13..5f9c9b2c965 100644 --- a/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Maven.gitlab-ci.yml @@ -17,7 +17,7 @@ variables: # This will supress any download for dependencies and plugins or upload messages which would clutter the console log. # `showDateTime` will show the passed time in milliseconds. You need to specify `--batch-mode` to make this work. - MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN -Dorg.slf4j.simpleLogger.showDateTime=true -Djava.awt.headless=true" + MAVEN_OPTS: "-Dhttps.protocols=TLSv1.2 -Dmaven.repo.local=.m2/repository -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN -Dorg.slf4j.simpleLogger.showDateTime=true -Djava.awt.headless=true" # As of Maven 3.3.0 instead of this you may define these options in `.mvn/maven.config` so the same config is used # when running from the command line. # `installAtEnd` and `deployAtEnd` are only effective with recent version of the corresponding plugins. diff --git a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml index ff7bdd32239..93cb31f48c0 100644 --- a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml @@ -1,6 +1,6 @@ # Official language image. Look for the different tagged releases at: # https://hub.docker.com/r/library/ruby/tags/ -image: "ruby:2.4" +image: "ruby:2.5" # Pick zero or more services to be used on all builds. # Only needed when using a docker container to run your tests in. diff --git a/vendor/licenses.csv b/vendor/licenses.csv index 7503160baa0..a462daf3067 100644 --- a/vendor/licenses.csv +++ b/vendor/licenses.csv @@ -7,28 +7,29 @@ @babel/template,7.0.0-beta.44,MIT @babel/traverse,7.0.0-beta.44,MIT @babel/types,7.0.0-beta.44,MIT -@gitlab-org/gitlab-svgs,1.25.0,SEE LICENSE IN LICENSE +@gitlab-org/gitlab-svgs,1.27.0,SEE LICENSE IN LICENSE +@gitlab-org/gitlab-ui,1.0.5,UNKNOWN @sindresorhus/is,0.7.0,MIT @types/jquery,2.0.48,MIT @vue/component-compiler-utils,1.2.1,MIT -@webassemblyjs/ast,1.5.10,MIT -@webassemblyjs/floating-point-hex-parser,1.5.10,MIT -@webassemblyjs/helper-api-error,1.5.10,MIT -@webassemblyjs/helper-buffer,1.5.10,MIT -@webassemblyjs/helper-code-frame,1.5.10,MIT -@webassemblyjs/helper-fsm,1.5.10,ISC -@webassemblyjs/helper-module-context,1.5.10,MIT -@webassemblyjs/helper-wasm-bytecode,1.5.10,MIT -@webassemblyjs/helper-wasm-section,1.5.10,MIT -@webassemblyjs/ieee754,1.5.10,Unknown -@webassemblyjs/leb128,1.5.10,Apache 2.0 -@webassemblyjs/utf8,1.5.10,MIT -@webassemblyjs/wasm-edit,1.5.10,MIT -@webassemblyjs/wasm-gen,1.5.10,MIT -@webassemblyjs/wasm-opt,1.5.10,MIT -@webassemblyjs/wasm-parser,1.5.10,MIT -@webassemblyjs/wast-parser,1.5.10,MIT -@webassemblyjs/wast-printer,1.5.10,MIT +@webassemblyjs/ast,1.5.13,MIT +@webassemblyjs/floating-point-hex-parser,1.5.13,MIT +@webassemblyjs/helper-api-error,1.5.13,MIT +@webassemblyjs/helper-buffer,1.5.13,MIT +@webassemblyjs/helper-code-frame,1.5.13,MIT +@webassemblyjs/helper-fsm,1.5.13,ISC +@webassemblyjs/helper-module-context,1.5.13,MIT +@webassemblyjs/helper-wasm-bytecode,1.5.13,MIT +@webassemblyjs/helper-wasm-section,1.5.13,MIT +@webassemblyjs/ieee754,1.5.13,MIT +@webassemblyjs/leb128,1.5.13,MIT +@webassemblyjs/utf8,1.5.13,MIT +@webassemblyjs/wasm-edit,1.5.13,MIT +@webassemblyjs/wasm-gen,1.5.13,MIT +@webassemblyjs/wasm-opt,1.5.13,MIT +@webassemblyjs/wasm-parser,1.5.13,MIT +@webassemblyjs/wast-parser,1.5.13,MIT +@webassemblyjs/wast-printer,1.5.13,MIT RedCloth,4.3.2,MIT abbrev,1.0.9,ISC abbrev,1.1.1,ISC @@ -36,6 +37,7 @@ accepts,1.3.4,MIT ace-rails-ap,4.1.2,MIT acorn,3.3.0,MIT acorn,5.6.2,MIT +acorn,5.7.1,MIT acorn-dynamic-import,3.0.0,MIT acorn-jsx,3.0.1,MIT actionmailer,4.2.10,MIT @@ -50,31 +52,29 @@ addressable,2.5.2,Apache 2.0 addressparser,1.0.1,MIT aes_key_wrap,1.0.1,MIT after,0.8.2,MIT -agent-base,2.1.1,MIT +agent-base,4.2.1,MIT ajv,5.5.2,MIT ajv,6.1.1,MIT ajv-keywords,2.1.1,MIT ajv-keywords,3.1.0,MIT akismet,2.0.0,MIT align-text,0.1.4,MIT -alphanum-sort,1.0.2,MIT amdefine,1.0.1,BSD-3-Clause OR MIT amqplib,0.5.2,MIT ansi-align,2.0.0,ISC +ansi-escapes,1.4.0,MIT ansi-escapes,3.0.0,MIT ansi-html,0.0.7,Apache 2.0 ansi-regex,2.1.1,MIT ansi-regex,3.0.0,MIT ansi-styles,2.2.1,MIT ansi-styles,3.2.1,MIT -anymatch,1.3.2,ISC anymatch,2.0.0,ISC append-transform,0.4.0,MIT aproba,1.2.0,ISC are-we-there-yet,1.1.4,ISC arel,6.0.4,MIT argparse,1.0.9,MIT -arr-diff,2.0.0,MIT arr-diff,4.0.0,MIT arr-flatten,1.1.0,MIT arr-union,3.1.0,MIT @@ -102,8 +102,8 @@ asset_sync,2.4.0,MIT assign-symbols,1.0.0,MIT ast-types,0.11.3,MIT async,1.5.2,MIT -async,2.1.5,MIT async,2.6.0,MIT +async,2.6.1,MIT async-each,1.0.1,MIT async-limiter,1.0.0,MIT asynckit,0.4.0,MIT @@ -111,7 +111,6 @@ atob,2.0.3,(MIT OR Apache-2.0) atomic,1.1.99,Apache 2.0 attr_encrypted,3.1.0,MIT attr_required,1.0.0,MIT -autoprefixer,6.7.7,MIT autosize,4.0.0,MIT aws-sign2,0.6.0,Apache 2.0 aws-sign2,0.7.0,Apache 2.0 @@ -138,7 +137,7 @@ babel-helper-regex,6.26.0,MIT babel-helper-remap-async-to-generator,6.24.1,MIT babel-helper-replace-supers,6.24.1,MIT babel-helpers,6.24.1,MIT -babel-loader,7.1.4,MIT +babel-loader,7.1.5,MIT babel-messages,6.23.0,MIT babel-plugin-check-es2015-constants,6.22.0,MIT babel-plugin-istanbul,4.1.6,New BSD @@ -182,6 +181,7 @@ babel-plugin-transform-exponentiation-operator,6.24.1,MIT babel-plugin-transform-object-rest-spread,6.23.0,MIT babel-plugin-transform-regenerator,6.26.0,MIT babel-plugin-transform-strict-mode,6.24.1,MIT +babel-polyfill,6.23.0,MIT babel-preset-es2015,6.24.1,MIT babel-preset-es2016,6.24.1,MIT babel-preset-es2017,6.24.1,MIT @@ -197,7 +197,6 @@ babosa,1.0.2,MIT babylon,6.18.0,MIT babylon,7.0.0-beta.44,MIT backo2,1.0.2,MIT -balanced-match,0.4.2,MIT balanced-match,1.0.0,MIT base,0.11.2,MIT base32,0.3.2,MIT @@ -213,8 +212,9 @@ better-assert,1.0.2,MIT bfj-node4,5.2.1,MIT big.js,3.1.3,MIT binary-extensions,1.11.0,MIT +binaryextensions,2.1.1,MIT bindata,2.4.3,ruby -bitsyntax,0.0.4,Unknown +bitsyntax,0.0.4,UNKNOWN bl,1.1.2,MIT blackst0ne-mermaid,7.1.0-fixed,MIT blob,0.0.4,MIT* @@ -226,11 +226,12 @@ boom,2.10.1,New BSD boom,4.3.1,New BSD boom,5.2.0,New BSD bootstrap,4.1.1,MIT +bootstrap,4.1.2,MIT +bootstrap-vue,2.0.0-rc.11,MIT bootstrap_form,2.7.0,MIT boxen,1.3.0,MIT brace-expansion,1.1.11,MIT braces,0.1.5,MIT -braces,1.8.5,MIT braces,2.3.1,MIT brorand,1.1.0,MIT browser,2.2.0,MIT @@ -240,7 +241,6 @@ browserify-des,1.0.0,MIT browserify-rsa,4.0.1,MIT browserify-sign,4.0.4,ISC browserify-zlib,0.2.0,MIT -browserslist,1.7.7,MIT buffer,4.9.1,MIT buffer-from,1.0.0,MIT buffer-indexof,1.1.0,MIT @@ -263,8 +263,6 @@ camelcase,1.2.1,MIT camelcase,2.1.1,MIT camelcase,4.1.0,MIT camelcase-keys,2.1.0,MIT -caniuse-api,1.6.1,MIT -caniuse-db,1.0.30000649,CC-BY-4.0 capture-stack-trace,1.0.0,MIT carrierwave,1.2.3,MIT caseless,0.11.0,Apache 2.0 @@ -274,22 +272,22 @@ center-align,0.1.3,MIT chalk,1.1.3,MIT chalk,2.4.1,MIT chardet,0.4.2,MIT +chardet,0.5.0,MIT charenc,0.0.2,New BSD charlock_holmes,0.7.6,MIT chart.js,1.0.2,MIT check-types,7.3.0,MIT -chokidar,1.7.0,MIT chokidar,2.0.2,MIT +chokidar,2.0.4,MIT chownr,1.0.1,ISC -chrome-trace-event,0.1.2,MIT +chrome-trace-event,1.0.0,MIT chronic,0.10.2,MIT chronic_duration,0.10.6,MIT chunky_png,1.3.5,MIT cipher-base,1.0.4,MIT circular-json,0.3.3,MIT -circular-json,0.5.1,MIT +circular-json,0.5.5,MIT citrus,3.0.2,MIT -clap,1.1.3,MIT class-utils,0.3.6,MIT classlist-polyfill,1.2.0,Unlicense cli-boxes,1.0.0,MIT @@ -298,19 +296,16 @@ cli-width,2.1.0,ISC clipboard,1.7.1,MIT cliui,2.1.0,ISC cliui,4.0.0,ISC -clone,1.0.3,MIT clone-response,1.0.2,MIT -co,3.0.6,MIT co,4.6.0,MIT -coa,1.0.1,MIT code-point-at,1.1.0,MIT +codesandbox-api,0.0.18,MIT +codesandbox-import-util-types,1.2.11,UNKNOWN +codesandbox-import-utils,1.2.11,UNKNOWN coercible,1.0.0,MIT collection-visit,1.0.0,MIT -color,0.11.4,MIT color-convert,1.9.1,MIT color-name,1.1.2,MIT -color-string,0.3.0,MIT -colormin,1.1.2,MIT colors,1.1.2,MIT combine-lists,1.0.1,MIT combined-stream,1.0.6,MIT @@ -361,28 +356,36 @@ cryptiles,2.0.5,New BSD cryptiles,3.1.2,New BSD crypto-browserify,3.12.0,MIT crypto-random-string,1.0.0,MIT -css-color-names,0.0.4,MIT -css-loader,0.28.11,MIT +css-loader,1.0.0,MIT css-selector-tokenizer,0.7.0,MIT css_parser,1.5.0,MIT cssesc,0.1.0,MIT -cssnano,3.10.0,MIT -csso,2.3.2,MIT currently-unhandled,0.4.1,MIT custom-event,1.0.1,MIT cyclist,0.2.2,MIT* d3,3.5.17,New BSD +d3,4.12.2,New BSD d3-array,1.2.1,New BSD d3-axis,1.0.8,New BSD d3-brush,1.0.4,New BSD +d3-chord,1.0.4,New BSD d3-collection,1.0.4,New BSD d3-color,1.0.3,New BSD d3-dispatch,1.0.3,New BSD d3-drag,1.2.1,New BSD +d3-dsv,1.0.8,New BSD d3-ease,1.0.3,New BSD +d3-force,1.1.0,New BSD d3-format,1.2.1,New BSD +d3-geo,1.9.1,New BSD +d3-hierarchy,1.1.5,New BSD d3-interpolate,1.1.6,New BSD d3-path,1.0.5,New BSD +d3-polygon,1.0.3,New BSD +d3-quadtree,1.0.3,New BSD +d3-queue,3.0.7,New BSD +d3-random,1.1.0,New BSD +d3-request,1.0.6,New BSD d3-scale,1.0.7,New BSD d3-selection,1.2.0,New BSD d3-shape,1.2.0,New BSD @@ -390,6 +393,8 @@ d3-time,1.0.8,New BSD d3-time-format,2.1.1,New BSD d3-timer,1.0.7,New BSD d3-transition,1.1.1,New BSD +d3-voronoi,1.1.2,New BSD +d3-zoom,1.7.1,New BSD dagre-d3-renderer,0.4.24,MIT dagre-layout,0.8.0,MIT dashdash,1.14.1,MIT @@ -398,7 +403,6 @@ date-format,1.2.0,MIT date-now,0.1.4,MIT dateformat,3.0.3,MIT de-indent,1.0.2,MIT -debug,2.2.0,MIT debug,2.6.8,MIT debug,2.6.9,MIT debug,3.1.0,MIT @@ -418,7 +422,6 @@ define-properties,1.1.2,MIT define-property,0.2.5,MIT define-property,1.0.0,MIT define-property,2.0.2,MIT -defined,1.0.0,MIT degenerator,1.0.4,MIT del,2.2.2,MIT del,3.0.0,MIT @@ -426,6 +429,7 @@ delayed-stream,1.0.0,MIT delegate,3.1.2,MIT delegates,1.0.0,MIT depd,1.1.1,MIT +depd,1.1.2,MIT des.js,1.0.0,MIT descendants_tracker,0.0.4,MIT destroy,1.0.4,MIT @@ -465,14 +469,15 @@ duplexer3,0.1.4,New BSD duplexify,3.5.3,MIT ecc-jsbn,0.1.1,MIT ed25519,1.2.4,MIT +editions,1.3.4,MIT ee-first,1.1.1,MIT ejs,2.5.9,Apache 2.0 -electron-to-chromium,1.3.3,ISC elliptic,6.4.0,MIT email_reply_trimmer,0.1.6,MIT emoji-unicode-version,0.2.1,MIT emojis-list,2.1.0,MIT encodeurl,1.0.2,MIT +encoding,0.1.12,MIT encryptor,3.0.0,MIT end-of-stream,1.4.1,MIT engine.io,3.1.5,MIT @@ -480,6 +485,7 @@ engine.io-client,3.1.5,MIT engine.io-parser,2.1.2,MIT enhanced-resolve,0.9.1,MIT enhanced-resolve,4.0.0,MIT +enhanced-resolve,4.1.0,MIT ent,2.2.0,MIT entities,1.1.1,Simplified BSD equalizer,0.0.11,MIT @@ -490,6 +496,8 @@ erubis,2.7.0,MIT es-abstract,1.10.0,MIT es-to-primitive,1.1.1,MIT es6-promise,3.0.2,MIT +es6-promise,4.2.4,MIT +es6-promisify,5.0.0,MIT escape-html,1.0.3,MIT escape-string-regexp,1.0.5,MIT escape_utils,1.1.1,MIT @@ -530,10 +538,8 @@ excon,0.62.0,MIT execa,0.7.0,MIT execjs,2.6.0,MIT expand-braces,0.1.2,MIT -expand-brackets,0.1.5,MIT expand-brackets,2.1.4,MIT expand-range,0.1.1,MIT -expand-range,1.8.2,MIT exports-loader,0.7.0,MIT express,4.16.2,MIT expression_parser,0.9.0,MIT @@ -541,7 +547,7 @@ extend,3.0.1,MIT extend-shallow,2.0.1,MIT extend-shallow,3.0.2,MIT external-editor,2.2.0,MIT -extglob,0.3.2,MIT +external-editor,3.0.0,MIT extglob,2.0.4,MIT extsprintf,1.3.0,MIT extsprintf,1.4.0,MIT @@ -561,10 +567,8 @@ figures,2.0.0,MIT file-entry-cache,2.0.0,MIT file-loader,1.1.11,MIT file-uri-to-path,1.0.0,MIT -filename-regex,2.0.1,MIT fileset,2.0.3,MIT filesize,3.6.0,New BSD -fill-range,2.2.3,MIT fill-range,4.0.0,MIT finalhandler,1.1.0,MIT find-cache-dir,1.0.0,MIT @@ -572,7 +576,6 @@ find-root,1.1.0,MIT find-up,1.1.2,MIT find-up,2.1.0,MIT flat-cache,1.2.2,MIT -flatten,1.0.2,MIT flipper,0.13.0,MIT flipper-active_record,0.13.0,MIT flipper-active_support_cache_store,0.13.0,MIT @@ -591,13 +594,12 @@ follow-redirects,1.0.0,MIT follow-redirects,1.2.6,MIT font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License" for-in,1.0.2,MIT -for-own,0.1.5,MIT foreach,2.0.5,MIT forever-agent,0.6.1,Apache 2.0 form-data,2.0.0,MIT -form-data,2.1.4,MIT form-data,2.3.2,MIT formatador,0.2.5,MIT +formdata-polyfill,3.0.11,MIT forwarded,0.1.2,MIT fragment-cache,0.2.1,MIT fresh,0.5.2,MIT @@ -620,13 +622,13 @@ generate-object-property,1.2.0,MIT get-caller-file,1.0.2,ISC get-stdin,4.0.1,MIT get-stream,3.0.0,MIT -get-uri,2.0.1,MIT +get-uri,2.0.2,MIT get-value,2.0.6,MIT get_process_mem,0.2.0,MIT getpass,0.1.7,MIT gettext_i18n_rails,1.8.0,MIT gettext_i18n_rails_js,1.3.0,MIT -gitaly-proto,0.105.0,MIT +gitaly-proto,0.112.0,MIT github-linguist,5.3.3,MIT github-markup,1.7.0,MIT gitlab-flowdock-git-hook,1.0.1,MIT @@ -637,8 +639,6 @@ gitlab-markup,1.6.4,MIT gitlab_omniauth-ldap,2.0.4,MIT glob,5.0.15,ISC glob,7.1.2,ISC -glob-base,0.3.0,MIT -glob-parent,2.0.0,ISC glob-parent,3.1.0,ISC global-dirs,0.1.1,MIT global-modules-path,2.1.0,Apache 2.0 @@ -660,16 +660,17 @@ gpgme,2.0.13,LGPL-2.1+ graceful-fs,4.1.11,ISC grape,1.0.3,MIT grape-entity,0.7.1,MIT -grape-path-helpers,1.0.5,MIT +grape-path-helpers,1.0.6,MIT grape_logging,1.7.0,MIT graphiql-rails,1.4.10,MIT graphlib,2.1.1,MIT graphql,1.8.1,MIT grpc,1.11.0,Apache 2.0 gzip-size,4.1.0,MIT -hamlit,2.6.1,MIT +hamlit,2.8.8,MIT handle-thing,1.2.5,MIT handlebars,4.0.6,MIT +hangouts-chat,0.0.5,MIT har-schema,2.0.0,ISC har-validator,2.0.6,ISC har-validator,5.0.3,ISC @@ -704,9 +705,8 @@ hoek,4.2.1,New BSD home-or-tmp,2.0.0,MIT hosted-git-info,2.2.0,ISC hpack.js,2.1.6,MIT -html-comment-regex,1.1.1,MIT html-entities,1.2.0,MIT -html-pipeline,2.8.3,MIT +html-pipeline,2.8.4,MIT html2text,0.2.0,MIT htmlentities,4.3.4,MIT htmlparser2,3.9.2,MIT @@ -715,9 +715,10 @@ http-cache-semantics,3.8.1,Simplified BSD http-cookie,1.0.3,MIT http-deceiver,1.2.7,MIT http-errors,1.6.2,MIT +http-errors,1.6.3,MIT http-form_data,1.0.3,MIT http-proxy,1.16.2,MIT -http-proxy-agent,1.0.0,MIT +http-proxy-agent,2.1.0,MIT http-proxy-middleware,0.18.0,MIT http-signature,1.1.1,MIT http-signature,1.2.0,MIT @@ -727,7 +728,7 @@ httpclient,2.8.3,ruby httpntlm,1.6.1,MIT httpreq,0.4.24,MIT https-browserify,1.0.0,MIT -https-proxy-agent,1.0.0,MIT +https-proxy-agent,2.2.1,MIT i18n,0.9.5,MIT icalendar,2.4.1,ruby ice_nine,0.11.2,MIT @@ -749,25 +750,24 @@ imurmurhash,0.1.4,MIT indent-string,2.1.0,MIT indexes-of,1.0.1,MIT indexof,0.0.1,MIT* -inflection,1.10.0,MIT +inflection,1.12.0,MIT inflection,1.3.8,MIT inflight,1.0.6,ISC influxdb,0.2.3,MIT inherits,2.0.1,ISC inherits,2.0.3,ISC ini,1.3.5,ISC +inquirer,3.0.6,MIT inquirer,3.3.0,MIT -inquirer,5.2.0,MIT +inquirer,6.0.0,MIT internal-ip,1.2.0,MIT interpret,1.1.0,MIT into-stream,3.1.0,MIT invariant,2.2.2,New BSD invert-kv,1.0.0,MIT -ip,1.0.1,MIT ip,1.1.5,MIT ipaddr.js,1.6.0,MIT ipaddress,0.8.3,MIT -is-absolute-url,2.1.0,MIT is-accessor-descriptor,0.1.6,MIT is-accessor-descriptor,1.0.0,MIT is-arrayish,0.2.1,MIT @@ -780,16 +780,12 @@ is-data-descriptor,1.0.0,MIT is-date-object,1.0.1,MIT is-descriptor,0.1.6,MIT is-descriptor,1.0.2,MIT -is-dotfile,1.0.3,MIT -is-equal-shallow,0.1.3,MIT is-extendable,0.1.1,MIT is-extendable,1.0.1,MIT -is-extglob,1.0.0,MIT is-extglob,2.1.1,MIT is-finite,1.0.2,MIT is-fullwidth-code-point,1.0.0,MIT is-fullwidth-code-point,2.0.0,MIT -is-glob,2.0.1,MIT is-glob,3.1.0,MIT is-glob,4.0.0,MIT is-installed-globally,0.1.0,MIT @@ -797,7 +793,6 @@ is-my-ip-valid,1.0.0,MIT is-my-json-valid,2.17.2,MIT is-npm,1.0.0,MIT is-number,0.1.1,MIT -is-number,2.1.0,MIT is-number,3.0.0,MIT is-number,4.0.0,MIT is-obj,1.0.1,MIT @@ -808,8 +803,6 @@ is-path-in-cwd,1.0.0,MIT is-path-inside,1.0.0,MIT is-plain-obj,1.1.0,MIT is-plain-object,2.0.4,MIT -is-posix-bracket,0.1.1,MIT -is-primitive,2.0.0,MIT is-promise,2.1.0,MIT is-property,1.0.2,MIT is-redirect,1.0.0,MIT @@ -817,7 +810,6 @@ is-regex,1.0.4,MIT is-resolvable,1.0.0,MIT is-retry-allowed,1.1.0,MIT is-stream,1.1.0,MIT -is-svg,2.1.0,MIT is-symbol,1.0.1,MIT is-typedarray,1.0.0,MIT is-utf8,0.2.1,MIT @@ -840,8 +832,10 @@ istanbul-lib-instrument,1.10.1,New BSD istanbul-lib-report,1.1.2,New BSD istanbul-lib-source-maps,1.2.2,New BSD istanbul-reports,1.1.3,New BSD +istextorbinary,2.2.1,MIT isurl,1.0.0,MIT jasmine-core,2.9.0,MIT +jasmine-diff,0.1.3,MIT jasmine-jquery,2.1.1,MIT jed,1.1.1,MIT jira-ruby,1.4.1,MIT @@ -849,11 +843,9 @@ jquery,3.3.1,MIT jquery-atwho-rails,1.3.2,MIT jquery-ujs,1.2.2,MIT jquery.waitforimages,2.2.0,MIT -js-base64,2.1.9,New BSD js-cookie,2.1.3,MIT js-tokens,3.0.2,MIT js-yaml,3.11.0,MIT -js-yaml,3.7.0,MIT jsbn,0.1.1,MIT jsesc,0.5.0,MIT jsesc,1.3.0,MIT @@ -877,13 +869,13 @@ kaminari,1.0.1,MIT kaminari-actionview,1.0.1,MIT kaminari-activerecord,1.0.1,MIT kaminari-core,1.0.1,MIT -karma,2.0.2,MIT +karma,2.0.4,MIT karma-chrome-launcher,2.2.0,MIT karma-coverage-istanbul-reporter,1.4.2,MIT -karma-jasmine,1.1.1,MIT +karma-jasmine,1.1.2,MIT karma-mocha-reporter,2.2.5,MIT karma-sourcemap-loader,0.3.7,MIT -karma-webpack,3.0.0,MIT +karma-webpack,4.0.0-beta.0,MIT katex,0.8.3,MIT keyv,3.0.0,MIT kgio,2.10.0,LGPL-2.1+ @@ -897,7 +889,6 @@ latest-version,3.1.0,MIT lazy-cache,1.0.4,MIT lazy-cache,2.0.2,MIT lcid,1.0.0,MIT -leb,0.3.0,Apache 2.0 levn,0.3.0,MIT libbase64,0.1.0,MIT libmime,3.0.0,MIT @@ -915,43 +906,44 @@ lodash,4.17.10,MIT lodash,4.17.4,MIT lodash.camelcase,4.3.0,MIT lodash.clonedeep,4.5.0,MIT +lodash.debounce,4.0.8,MIT lodash.escaperegexp,4.1.2,MIT +lodash.get,4.4.2,MIT +lodash.isequal,4.5.0,MIT lodash.kebabcase,4.1.1,MIT -lodash.memoize,4.1.2,MIT lodash.mergewith,4.6.0,MIT lodash.snakecase,4.1.1,MIT -lodash.uniq,4.5.0,MIT +lodash.startcase,4.4.0,MIT lodash.upperfirst,4.3.1,MIT log-symbols,2.2.0,MIT -log4js,2.5.3,Apache 2.0 +log4js,2.11.0,Apache 2.0 logging,2.2.2,MIT loggly,1.1.1,MIT loglevel,1.4.1,MIT loglevelnext,1.0.3,MIT lograge,0.10.0,MIT long,3.2.0,Apache 2.0 +long,4.0.0,Apache 2.0 longest,1.0.1,MIT loofah,2.2.2,MIT loose-envify,1.3.1,MIT loud-rejection,1.6.0,MIT lowercase-keys,1.0.0,MIT lru-cache,2.2.4,MIT -lru-cache,2.6.5,ISC lru-cache,4.1.3,ISC -macaddress,0.2.8,MIT +lz-string,1.4.4,WTFPL mail,2.7.0,MIT mail_room,0.9.1,MIT mailcomposer,4.0.1,MIT -mailgun-js,0.7.15,MIT +mailgun-js,0.18.1,MIT make-dir,1.2.0,MIT mamacro,0.0.3,MIT map-cache,0.2.2,MIT map-obj,1.0.1,MIT -map-stream,0.1.0,Unknown +map-stream,0.1.0,UNKNOWN map-visit,1.0.0,MIT marked,0.3.12,MIT match-at,0.1.1,MIT -math-expression-evaluator,1.2.16,MIT md5.js,1.3.4,MIT media-typer,0.3.0,MIT mem,1.1.0,MIT @@ -963,7 +955,6 @@ merge-descriptors,1.0.1,MIT merge-source-map,1.1.0,MIT method_source,0.8.2,MIT methods,1.1.2,MIT -micromatch,2.3.11,MIT micromatch,3.1.10,MIT miller-rabin,4.0.1,MIT mime,1.4.1,MIT @@ -996,8 +987,8 @@ monaco-editor-webpack-plugin,1.4.0,MIT mousetrap,1.4.6,Apache 2.0 mousetrap-rails,1.4.6,"MIT,Apache" move-concurrently,1.0.1,ISC -ms,0.7.1,MIT ms,2.0.0,MIT +msgpack,1.2.4,Apache 2.0 multi_json,1.13.1,MIT multi_xml,0.6.0,MIT multicast-dns,6.1.1,MIT @@ -1018,6 +1009,7 @@ net-ssh,5.0.1,MIT netmask,1.0.6,MIT netrc,0.11.0,MIT nice-try,1.0.4,MIT +node-fetch,1.6.3,MIT node-forge,0.6.33,New BSD node-libs-browser,2.1.0,MIT node-pre-gyp,0.10.0,New BSD @@ -1029,23 +1021,20 @@ nodemailer-shared,1.1.0,MIT nodemailer-smtp-pool,2.8.2,MIT nodemailer-smtp-transport,2.7.2,MIT nodemailer-wellknown,0.1.10,MIT -nodemon,1.17.3,MIT -nokogiri,1.8.3,MIT +nodemon,1.18.2,MIT +nokogiri,1.8.4,MIT nokogumbo,1.5.0,Apache 2.0 nopt,1.0.10,MIT nopt,3.0.6,ISC nopt,4.0.1,ISC normalize-package-data,2.4.0,Simplified BSD normalize-path,2.1.1,MIT -normalize-range,0.1.2,MIT -normalize-url,1.9.1,MIT normalize-url,2.0.1,MIT npm-bundled,1.0.3,ISC npm-packlist,1.1.10,ISC npm-run-path,2.0.2,MIT npmlog,4.1.2,ISC null-check,1.0.0,MIT -num2fraction,1.2.2,MIT number-is-nan,1.0.1,MIT numerizer,0.1.1,MIT oauth,0.5.4,MIT @@ -1056,7 +1045,6 @@ object-component,0.0.3,MIT* object-copy,0.1.0,MIT object-keys,1.0.11,MIT object-visit,1.0.1,MIT -object.omit,2.0.1,MIT object.pick,1.3.0,MIT obuf,1.1.1,MIT octokit,4.9.0,MIT @@ -1082,7 +1070,9 @@ on-finished,2.3.0,MIT on-headers,1.0.1,MIT once,1.4.0,ISC onetime,2.0.1,MIT +opencollective,1.0.3,MIT opener,1.4.3,(WTFPL OR MIT) +opn,4.0.2,MIT opn,5.2.0,MIT optimist,0.6.1,MIT optionator,0.8.2,MIT @@ -1103,13 +1093,12 @@ p-locate,2.0.0,MIT p-map,1.1.1,MIT p-timeout,2.0.1,MIT p-try,1.0.0,MIT -pac-proxy-agent,1.1.0,MIT -pac-resolver,2.0.0,MIT +pac-proxy-agent,2.0.2,MIT +pac-resolver,3.0.0,MIT package-json,4.0.1,MIT pako,1.0.6,(MIT AND Zlib) parallel-transform,1.1.0,MIT parse-asn1,5.1.0,ISC -parse-glob,3.0.4,MIT parse-json,2.2.0,MIT parseqs,0.0.5,MIT parseuri,0.0.5,MIT @@ -1151,47 +1140,19 @@ popper.js,1.14.3,MIT portfinder,1.0.13,MIT posix-character-classes,0.1.1,MIT posix-spawn,0.3.13,MIT -postcss,5.2.16,MIT postcss,6.0.22,MIT -postcss-calc,5.3.1,MIT -postcss-colormin,2.2.2,MIT -postcss-convert-values,2.6.1,MIT -postcss-discard-comments,2.0.4,MIT -postcss-discard-duplicates,2.1.0,MIT -postcss-discard-empty,2.1.0,MIT -postcss-discard-overridden,0.1.1,MIT -postcss-discard-unused,2.2.3,MIT -postcss-filter-plugins,2.0.2,MIT -postcss-merge-idents,2.1.7,MIT -postcss-merge-longhand,2.0.2,MIT -postcss-merge-rules,2.1.2,MIT -postcss-message-helpers,2.0.0,MIT -postcss-minify-font-values,1.0.5,MIT -postcss-minify-gradients,1.0.5,MIT -postcss-minify-params,1.2.2,MIT -postcss-minify-selectors,2.1.1,MIT +postcss,6.0.23,MIT postcss-modules-extract-imports,1.2.0,ISC postcss-modules-local-by-default,1.2.0,MIT postcss-modules-scope,1.1.0,ISC postcss-modules-values,1.3.0,ISC -postcss-normalize-charset,1.1.1,MIT -postcss-normalize-url,3.0.8,MIT -postcss-ordered-values,2.2.3,MIT -postcss-reduce-idents,2.4.0,MIT -postcss-reduce-initial,1.0.1,MIT -postcss-reduce-transforms,1.0.4,MIT -postcss-selector-parser,2.2.3,MIT postcss-selector-parser,3.1.1,MIT -postcss-svgo,2.1.6,MIT -postcss-unique-selectors,2.0.2,MIT postcss-value-parser,3.3.0,MIT -postcss-zindex,2.2.0,MIT prelude-ls,1.1.2,MIT premailer,1.10.4,New BSD premailer-rails,1.9.7,MIT prepend-http,1.0.4,MIT prepend-http,2.0.0,MIT -preserve,0.2.0,MIT prettier,1.12.1,MIT prismjs,1.6.0,MIT private,0.1.8,MIT @@ -1199,10 +1160,12 @@ process,0.11.10,MIT process-nextick-args,1.0.7,MIT process-nextick-args,2.0.0,MIT progress,2.0.0,MIT -prometheus-client-mmap,0.9.3,Apache 2.0 +prometheus-client-mmap,0.9.4,Apache 2.0 promise-inflight,1.0.1,ISC +promisify-call,2.0.4,MIT proxy-addr,2.0.3,MIT -proxy-agent,2.0.0,MIT +proxy-agent,3.0.1,MIT +proxy-from-env,1.0.0,MIT prr,0.0.0,MIT prr,1.0.1,MIT ps-tree,1.1.0,MIT @@ -1215,12 +1178,9 @@ pumpify,1.4.0,MIT punycode,1.3.2,MIT punycode,1.4.1,MIT pyu-ruby-sasl,0.0.3.3,MIT -q,1.4.1,MIT -q,1.5.0,MIT qjobs,1.2.0,MIT qs,6.2.3,New BSD qs,6.5.1,New BSD -query-string,4.3.2,MIT query-string,5.1.1,MIT querystring,0.2.0,MIT querystring-es3,0.2.1,MIT @@ -1243,16 +1203,17 @@ railties,4.2.10,MIT rainbow,2.2.2,MIT raindrops,0.18.0,LGPL-2.1+ rake,12.3.1,MIT -randomatic,1.1.7,MIT randombytes,2.0.6,MIT randomfill,1.0.4,MIT range-parser,1.2.0,MIT raphael,2.2.7,MIT raven-js,3.22.1,Simplified BSD raw-body,2.3.2,MIT +raw-body,2.3.3,MIT raw-loader,0.5.1,MIT rb-fsevent,0.10.2,MIT rb-inotify,0.9.10,MIT +rbtrace,0.4.10,MIT rc,1.2.5,(BSD-2-Clause OR MIT OR Apache-2.0) rdoc,6.0.4,ruby re2,1.1.1,New BSD @@ -1279,12 +1240,10 @@ redis-parser,2.6.0,MIT redis-rack,2.0.4,MIT redis-rails,5.0.2,MIT redis-store,1.4.1,MIT -reduce-css-calc,1.3.0,MIT -reduce-function-call,1.0.2,MIT regenerate,1.3.2,MIT +regenerator-runtime,0.10.5,MIT regenerator-runtime,0.11.0,MIT regenerator-transform,0.10.1,BSD -regex-cache,0.4.4,MIT regex-not,1.0.2,MIT regexpu-core,1.0.0,MIT regexpu-core,2.0.0,MIT @@ -1323,7 +1282,7 @@ rimraf,2.6.2,ISC rinku,2.0.0,ISC ripemd160,2.0.1,MIT rotp,2.1.2,MIT -rouge,3.1.1,MIT +rouge,3.2.0,MIT rqrcode,0.7.0,MIT rqrcode-rails3,0.1.7,MIT ruby-enum,0.7.2,MIT @@ -1338,21 +1297,22 @@ rufus-scheduler,3.4.0,MIT rugged,0.27.2,MIT run-async,2.3.0,MIT run-queue,1.0.3,ISC +rw,1.3.3,New BSD +rx,4.1.0,Apache 2.0 rx-lite,4.0.8,Apache 2.0 rx-lite-aggregates,4.0.8,Apache 2.0 -rxjs,5.5.10,Apache 2.0 +rxjs,6.2.1,Apache 2.0 safe-buffer,5.1.1,MIT safe-buffer,5.1.2,MIT safe-regex,1.1.0,MIT safe_yaml,1.0.4,MIT safer-buffer,2.1.2,MIT -sanitize,4.6.5,MIT +sanitize,4.6.6,MIT sanitize-html,1.16.3,MIT sass,3.5.5,MIT sass-listen,4.0.0,MIT sass-rails,5.0.6,MIT sawyer,0.8.1,MIT -sax,1.2.2,ISC sax,1.2.4,ISC schema-utils,0.4.5,MIT seed-fu,2.3.7,MIT @@ -1361,7 +1321,6 @@ select-hose,2.0.0,MIT select2,3.5.2-browserify,Apache* select2-rails,3.5.9.3,MIT selfsigned,1.10.1,MIT -semver,5.0.3,ISC semver,5.5.0,ISC semver-diff,2.1.0,MIT send,0.16.1,MIT @@ -1393,6 +1352,8 @@ slack-notifier,1.5.1,MIT slash,1.0.0,MIT slice-ansi,1.0.0,MIT smart-buffer,1.1.15,MIT +smart-buffer,4.0.1,MIT +smooshpack,0.0.48,SEE LICENSE.MD IN ROOT smtp-connection,2.12.0,MIT snapdragon,0.8.1,MIT snapdragon-node,2.1.1,MIT @@ -1407,8 +1368,9 @@ sockjs,0.3.19,MIT sockjs-client,1.1.4,MIT socks,1.1.10,MIT socks,1.1.9,MIT -socks-proxy-agent,2.1.1,MIT -sort-keys,1.1.2,MIT +socks,2.2.1,MIT +socks-proxy-agent,3.0.1,MIT +socks-proxy-agent,4.0.1,MIT sort-keys,2.0.0,MIT sortablejs,1.7.0,MIT source-list-map,2.0.0,MIT @@ -1442,6 +1404,7 @@ state_machines-activerecord,0.5.1,MIT static-extend,0.1.2,MIT statuses,1.3.1,MIT statuses,1.4.0,MIT +statuses,1.5.0,MIT stickyfilljs,2.0.5,MIT stream-browserify,2.0.1,MIT stream-combiner,0.0.4,MIT @@ -1469,18 +1432,17 @@ supports-color,2.0.0,MIT supports-color,3.2.3,MIT supports-color,5.4.0,MIT svg4everybody,2.1.9,CC0-1.0 -svgo,0.7.2,MIT -symbol-observable,1.0.1,MIT sys-filesystem,1.1.6,Artistic 2.0 table,4.0.2,New BSD tapable,0.1.10,MIT tapable,1.0.0,MIT tar,4.4.4,ISC -temple,0.7.7,MIT +temple,0.8.0,MIT term-size,1.2.0,MIT test-exclude,4.2.1,ISC text,1.3.1,MIT text-table,0.2.0,MIT +textextensions,2.2.0,MIT thor,0.19.4,MIT thread_safe,0.3.6,Apache 2.0 three,0.84.0,MIT @@ -1490,7 +1452,7 @@ through,2.3.8,MIT through2,2.0.3,MIT thunkify,2.1.2,MIT thunky,0.1.0,MIT* -tilt,2.0.6,MIT +tilt,2.0.8,MIT timeago.js,3.0.2,MIT timed-out,4.0.1,MIT timers-browserify,2.0.10,MIT @@ -1511,9 +1473,11 @@ tough-cookie,2.3.3,New BSD traverse,0.6.6,MIT trim-newlines,1.0.0,MIT trim-right,1.0.1,MIT +trollop,2.1.3,MIT truncato,0.7.10,MIT tryer,1.0.0,MIT tryit,1.0.3,MIT +tslib,1.9.3,Apache 2.0 tsscmp,1.0.5,MIT tty-browserify,0.0.0,MIT tunnel-agent,0.4.3,Apache 2.0 @@ -1540,8 +1504,6 @@ unicorn,5.1.0,ruby unicorn-worker-killer,0.4.4,ruby union-value,1.0.0,MIT uniq,1.0.1,MIT -uniqid,4.1.1,MIT -uniqs,2.0.0,MIT unique-filename,1.1.0,ISC unique-slug,2.0.0,ISC unique-string,1.0.0,MIT @@ -1549,10 +1511,10 @@ unpipe,1.0.0,MIT unset-value,1.0.0,MIT unzip-response,2.0.1,MIT upath,1.0.5,MIT +upath,1.1.0,MIT update-notifier,2.3.0,Simplified BSD urix,0.1.0,MIT url,0.11.0,MIT -url-join,2.0.5,MIT url-join,4.0.0,MIT url-loader,1.0.1,MIT url-parse,1.0.5,MIT @@ -1572,7 +1534,6 @@ validate-npm-package-license,3.0.1,Apache 2.0 validates_hostname,1.0.6,MIT vary,1.1.1,MIT vary,1.1.2,MIT -vendors,1.0.1,MIT verror,1.10.0,MIT version_sorter,2.1.0,MIT virtus,1.0.5,MIT @@ -1582,8 +1543,9 @@ vmstat,2.3.0,MIT void-elements,2.0.1,MIT vue,2.5.16,MIT vue-eslint-parser,2.0.3,MIT +vue-functional-data-merge,2.0.6,MIT vue-hot-reload-api,2.3.0,MIT -vue-loader,15.2.0,MIT +vue-loader,15.2.4,MIT vue-resource,1.5.0,MIT vue-router,3.0.1,MIT vue-style-loader,4.1.0,MIT @@ -1594,10 +1556,9 @@ vuex,3.0.1,MIT warden,1.2.7,MIT watchpack,1.5.0,MIT wbuf,1.7.2,MIT -webpack,4.11.1,MIT -webpack-bundle-analyzer,2.11.1,MIT -webpack-cli,3.0.2,MIT -webpack-dev-middleware,2.0.6,MIT +webpack,4.16.0,MIT +webpack-bundle-analyzer,2.13.1,MIT +webpack-cli,3.0.8,MIT webpack-dev-middleware,3.1.3,MIT webpack-dev-server,3.1.4,MIT webpack-log,1.2.0,MIT @@ -1607,13 +1568,13 @@ webpack-stats-plugin,0.2.1,MIT websocket-driver,0.6.5,MIT websocket-extensions,0.1.1,MIT when,3.7.8,MIT -whet.extend,0.9.9,MIT which,1.3.0,ISC which-module,2.0.0,ISC wide-align,1.1.2,ISC widest-line,2.0.0,MIT wikicloth,0.8.1,MIT window-size,0.1.0,MIT +with-callback,1.0.2,MIT wordwrap,0.0.2,MIT wordwrap,0.0.3,MIT wordwrap,1.0.0,MIT @@ -1627,9 +1588,11 @@ ws,3.3.3,MIT ws,4.0.0,MIT xdg-basedir,3.0.0,MIT xml-simple,1.1.5,ruby +xmlhttprequest,1.8.0,MIT xmlhttprequest-ssl,1.5.5,MIT xregexp,2.0.0,MIT xtend,4.0.1,MIT +xterm,3.5.0,MIT y18n,3.2.1,ISC y18n,4.0.0,ISC yallist,2.1.2,ISC diff --git a/yarn.lock b/yarn.lock index f6e3b84c84b..c1e9d0ab73e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0: version "1.11.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" +binaryextensions@2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935" + bitsyntax@~0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82" @@ -1776,6 +1780,22 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" +codesandbox-api@^0.0.18: + version "0.0.18" + resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.18.tgz#56b96b37533f80d20c21861e5e477d3557e613ca" + +codesandbox-import-util-types@^1.2.11: + version "1.2.11" + resolved "https://registry.yarnpkg.com/codesandbox-import-util-types/-/codesandbox-import-util-types-1.2.11.tgz#68e812f21d6b309e9a52eec5cf027c3e63b4c703" + +codesandbox-import-utils@^1.2.3: + version "1.2.11" + resolved "https://registry.yarnpkg.com/codesandbox-import-utils/-/codesandbox-import-utils-1.2.11.tgz#b88423a4a7c785175c784c84e87f5950820280e1" + dependencies: + codesandbox-import-util-types "^1.2.11" + istextorbinary "^2.2.1" + lz-string "^1.4.4" + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" @@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1: dependencies: jsbn "~0.1.0" +editions@^1.3.3: + version "1.3.4" + resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1: agent-base "^4.1.0" debug "^3.1.0" -iconv-lite@0.4: +iconv-lite@0.4, iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13: version "0.4.23" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" dependencies: @@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" -iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13: - version "0.4.23" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" - dependencies: - safer-buffer ">= 2.1.2 < 3" - icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -4490,6 +4508,14 @@ istanbul@^0.4.5: which "^1.1.1" wordwrap "^1.0.0" +istextorbinary@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.2.1.tgz#a5231a08ef6dd22b268d0895084cf8d58b5bec53" + dependencies: + binaryextensions "2" + editions "^1.3.3" + textextensions "2" + isurl@^1.0.0-alpha5: version "1.0.0" resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" @@ -4839,6 +4865,10 @@ lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + lodash.kebabcase@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" @@ -4948,6 +4978,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2: pseudomap "^1.0.2" yallist "^2.1.2" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + mailcomposer@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4" @@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3" +smooshpack@^0.0.48: + version "0.0.48" + resolved "https://registry.yarnpkg.com/smooshpack/-/smooshpack-0.0.48.tgz#6fbeaaf59226a1fe500f56aa17185eed377d2823" + dependencies: + codesandbox-api "^0.0.18" + codesandbox-import-utils "^1.2.3" + lodash.isequal "^4.5.0" + smtp-connection@2.12.0: version "2.12.0" resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1" @@ -7240,6 +7282,10 @@ text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" +textextensions@2: + version "2.2.0" + resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.2.0.tgz#38ac676151285b658654581987a0ce1a4490d286" + three-orbit-controls@^82.1.0: version "82.1.0" resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4" |