diff options
Diffstat (limited to 'app/assets')
104 files changed, 2820 insertions, 1134 deletions
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%; } |