diff options
400 files changed, 12413 insertions, 2527 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/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 5bc3c2c4d21..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 ); }, }, 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/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/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/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/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/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/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/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/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/avatarable.rb b/app/models/concerns/avatarable.rb index 095897b08e3..a6d604a580d 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -19,7 +19,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 +40,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. @@ -64,7 +65,7 @@ module Avatarable url_base << gitlab_config.relative_url_root end - url_base + avatar.local_url + url_base + 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/issuable.rb b/app/models/concerns/issuable.rb index b93c1145f82..1588f76989b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -154,7 +154,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 +243,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/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 9155d82d567..65cc7a751f9 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -42,6 +42,8 @@ module ReactiveCaching extend ActiveSupport::Concern + InvalidateReactiveCache = Class.new(StandardError) + included do class_attribute :reactive_cache_lease_timeout @@ -63,15 +65,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 +102,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/sortable.rb b/app/models/concerns/sortable.rb index cb76ae971d4..409255fb68b 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -6,6 +6,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/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/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/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/merge_request.rb b/app/models/merge_request.rb index acad8b91e9f..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, @@ -1041,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 @@ -1089,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 + # 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 - 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 - - 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/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/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/project.rb b/app/models/project.rb index cb4d2610e0d..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? 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/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/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/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/edit.html.haml b/app/views/projects/edit.html.haml index 0ff88b82ae6..f483fad6142 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -51,7 +51,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 } 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/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/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/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/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 2bef2971f29..c132f787530 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) 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| @@ -637,6 +638,9 @@ ActiveRecord::Schema.define(version: 20180726172057) 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| @@ -1988,7 +1992,7 @@ ActiveRecord::Schema.define(version: 20180726172057) 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 @@ -1998,10 +2002,12 @@ ActiveRecord::Schema.define(version: 20180726172057) 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 @@ -2389,6 +2395,7 @@ ActiveRecord::Schema.define(version: 20180726172057) 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/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/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 |:---:|:---:| | ![Labels sidebar](img/labels_sidebar.png) | ![Labels sidebar assign](img/labels_sidebar_assign.png) | +## 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: + +![Labels project list search](img/labels_project_list_search.png) + +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/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/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/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/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/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/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/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/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/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_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/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 52c52517cca..6258bfa232f 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1204,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 @@ -1354,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 @@ -1393,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/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/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/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/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" |