diff options
51 files changed, 1285 insertions, 986 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index a00ca6199c2..3fce90ee723 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -282,6 +282,14 @@ Cop/ActiveRecordAssociationReload: Gitlab/AvoidFeatureGet: Enabled: true +RSpec/TimecopFreeze: + Enabled: false + AutoCorrect: true + Include: + - 'spec/**/*.rb' + - 'ee/spec/**/*.rb' + - 'qa/spec/**/*.rb' + Naming/PredicateName: Enabled: true Exclude: diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index c86db28515f..3b285e3bc31 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,9 +1,8 @@ <script> -import { GlLink, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlModalDirective, GlSprintf, GlButton } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; -import loadingButton from '../../vue_shared/components/loading_button.vue'; import UninstallApplicationButton from './uninstall_application_button.vue'; import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue'; import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue'; @@ -12,7 +11,7 @@ import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants'; export default { components: { - loadingButton, + GlButton, identicon, GlLink, GlSprintf, @@ -390,16 +389,18 @@ export default { </div> <template v-if="updateAvailable || updateFailed || isUpdating"> <template v-if="updatingNeedsConfirmation"> - <loading-button + <gl-button v-gl-modal-directive="updateModalId" - class="btn btn-primary js-cluster-application-update-button mt-2" + class="js-cluster-application-update-button mt-2" + variant="info" + category="primary" :loading="isUpdating" :disabled="isUpdating" - :label="updateButtonLabel" data-qa-selector="update_button_with_confirmation" :data-qa-application="id" - /> - + > + {{ updateButtonLabel }} + </gl-button> <update-application-confirmation-modal :application="id" :application-title="title" @@ -407,16 +408,19 @@ export default { /> </template> - <loading-button + <gl-button v-else - class="btn btn-primary js-cluster-application-update-button mt-2" + class="js-cluster-application-update-button mt-2" + variant="info" + category="primary" :loading="isUpdating" :disabled="isUpdating" - :label="updateButtonLabel" data-qa-selector="update_button" :data-qa-application="id" @click="updateConfirmed" - /> + > + {{ updateButtonLabel }} + </gl-button> </template> </div> </div> @@ -431,16 +435,18 @@ export default { }}</a> </div> <div class="btn-group table-action-buttons"> - <loading-button + <gl-button v-if="displayInstallButton" :loading="installButtonLoading" :disabled="disabled || installButtonDisabled" - :label="installButtonLabel" class="js-cluster-application-install-button" + variant="default" data-qa-selector="install_button" :data-qa-application="id" @click="installClicked" - /> + > + {{ installButtonLabel }} + </gl-button> <uninstall-application-button v-if="displayUninstallButton" v-gl-modal-directive="uninstallModalId" diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue index 8465312d84d..73191d6d84d 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue @@ -1,5 +1,5 @@ <script> -import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlButton } from '@gitlab/ui'; import { APPLICATION_STATUS } from '~/clusters/constants'; import { __ } from '~/locale'; @@ -7,7 +7,7 @@ const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; export default { components: { - LoadingButton, + GlButton, }, props: { status: { @@ -30,5 +30,7 @@ export default { </script> <template> - <loading-button :label="label" :disabled="disabled" :loading="loading" /> + <gl-button :disabled="disabled" variant="default" :loading="loading"> + {{ label }} + </gl-button> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 27547ed8449..471d6e1f0aa 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1 +1,6 @@ -export const KUBERNETES_VERSIONS = [{ name: '1.14', value: '1.14' }]; +export const KUBERNETES_VERSIONS = [ + { name: '1.14', value: '1.14' }, + { name: '1.15', value: '1.15' }, + { name: '1.16', value: '1.16', default: true }, + { name: '1.17', value: '1.17' }, +]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index caf2729a4c7..5abff3c7831 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -56,6 +56,7 @@ export const createCluster = ({ dispatch, state }) => { environment_scope: state.environmentScope, managed: state.gitlabManagedCluster, provider_aws_attributes: { + kubernetes_version: state.kubernetesVersion, region: state.selectedRegion, vpc_id: state.selectedVpc, subnet_ids: state.selectedSubnet, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index d1337e7ea4a..ed51e95e434 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -1,6 +1,6 @@ import { KUBERNETES_VERSIONS } from '../constants'; -const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS; +const kubernetesVersion = KUBERNETES_VERSIONS.find(version => version.default).value; export default () => ({ createRolePath: null, diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js new file mode 100644 index 00000000000..c17f2d2efe4 --- /dev/null +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js @@ -0,0 +1,689 @@ +/* eslint-disable consistent-return */ +import $ from 'jquery'; +import { escape } from 'lodash'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { isObject } from '~/lib/utils/type_utility'; +import renderItem from './render'; +import { GitLabDropdownRemote } from './gl_dropdown_remote'; +import { GitLabDropdownInput } from './gl_dropdown_input'; +import { GitLabDropdownFilter } from './gl_dropdown_filter'; + +const LOADING_CLASS = 'is-loading'; + +const PAGE_TWO_CLASS = 'is-page-two'; + +const ACTIVE_CLASS = 'is-active'; + +const INDETERMINATE_CLASS = 'is-indeterminate'; + +let currentIndex = -1; + +const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; + +const SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`; + +const CURSOR_SELECT_SCROLL_PADDING = 5; + +const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)'; + +const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; + +export class GitLabDropdown { + constructor(el1, options) { + let selector; + let self; + this.el = el1; + this.options = options; + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); + self = this; + 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 = Boolean(this.options.highlight); + this.icon = Boolean(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 + if (typeof this.filterInput === 'string') { + this.filterInput = this.getElement(this.filterInput); + } + const searchFields = this.options.search ? this.options.search.fields : []; + if (this.options.data) { + // If we provided data + // data could be an array of objects or a group of arrays + if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) { + this.fullData = this.options.data; + currentIndex = -1; + this.parseData(this.options.data); + this.focusTextInput(); + } else { + this.remote = new GitLabDropdownRemote(this.options.data, { + dataType: this.options.dataType, + beforeSend: this.toggleLoading.bind(this), + success: data => { + this.fullData = data; + this.parseData(this.fullData); + this.focusTextInput(); + + // Update dropdown position since remote data may have changed dropdown size + this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); + + if ( + this.options.filterable && + this.filter && + this.filter.input && + this.filter.input.val() && + this.filter.input.val().trim() !== '' + ) { + return this.filter.input.trigger('input'); + } + }, + instance: this, + }); + } + } + if (this.noFilterInput.length) { + this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); + this.plainInput.onInput(this.addInput.bind(this)); + } + // Init filterable + if (this.options.filterable) { + this.filter = new GitLabDropdownFilter(this.filterInput, { + elIsInput: $(this.el).is('input'), + filterInputBlur: this.filterInputBlur, + filterByText: this.options.filterByText, + onFilter: this.options.onFilter, + remote: this.options.filterRemote, + query: this.options.data, + keys: searchFields, + instance: this, + elements: () => { + selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + return $(selector, this.dropdown); + }, + data: () => this.fullData, + callback: data => { + this.parseData(data); + if (this.filterInput.val() !== '') { + selector = SELECTABLE_CLASSES; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + if ($(this.el).is('input')) { + currentIndex = -1; + } else { + $(selector, this.dropdown) + .first() + .find('a') + .addClass('is-focused'); + currentIndex = 0; + } + } + }, + }); + } + // 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', e => { + // Escape key + if (e.which === 27) { + return $('.dropdown-menu-close', this.dropdown).trigger('click'); + } + }); + this.dropdown.on('blur', 'a', e => { + let $dropdownMenu; + let $relatedTarget; + if (e.relatedTarget != null) { + $relatedTarget = $(e.relatedTarget); + $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); + if ($dropdownMenu.length === 0) { + return this.dropdown.removeClass('show'); + } + } + }); + if (this.dropdown.find('.dropdown-toggle-page').length) { + this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => { + e.preventDefault(); + e.stopPropagation(); + return this.togglePage(); + }); + } + if (this.options.selectable) { + selector = '.dropdown-content a'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = '.dropdown-page-one .dropdown-content a'; + } + this.dropdown.on('click', selector, e => { + const $el = $(e.currentTarget); + const selected = self.rowClicked($el); + const selectedObj = selected ? selected[0] : null; + const 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); + } + + $el.trigger('blur'); + }); + } + } + + // Finds an element inside wrapper element + getElement(selector) { + return this.dropdown.find(selector); + } + + toggleLoading() { + return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); + } + + togglePage() { + const menu = $('.dropdown-menu', this.dropdown); + if (menu.hasClass(PAGE_TWO_CLASS)) { + if (this.remote) { + this.remote.execute(); + } + } + menu.toggleClass(PAGE_TWO_CLASS); + // Focus first visible input on active page + return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); + } + + parseData(data) { + let groupData; + let html; + this.renderedData = data; + if (this.options.filterable && data.length === 0) { + // render no matching results + html = [this.noResults()]; + } + // Handle array groups + else if (isObject(data)) { + html = []; + + Object.keys(data).forEach(name => { + groupData = data[name]; + html.push( + this.renderItem( + { + content: name, + type: 'header', + }, + name, + ), + ); + this.renderData(groupData, name).map(item => html.push(item)); + }); + } else { + // Render each row + html = this.renderData(data); + } + // Render the full menu + const fullHtml = this.renderMenu(html); + return this.appendMenu(fullHtml); + } + + renderData(data, group) { + return data.map((obj, index) => this.renderItem(obj, group || false, index)); + } + + shouldPropagate(e) { + let $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') + ) { + e.stopPropagation(); + + // This prevents automatic scrolling to the top + if ($target.closest('a').length) { + return false; + } + } + + return true; + } + } + + filteredFullData() { + return this.fullData.filter( + r => + typeof r === 'object' && + !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && + !Object.prototype.hasOwnProperty.call(r, 'header'), + ); + } + + opened(e) { + this.resetRows(); + this.addArrowKeyEvent(); + + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); + + // Makes indeterminate items effective + if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { + this.parseData(this.fullData); + } + + // Process the data to make sure rendered data + // 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), + ); + } + + const contentHtml = $('.dropdown-content', this.dropdown).html(); + if (this.remote && contentHtml === '') { + this.remote.execute(); + } else { + this.focusTextInput(); + } + + if (this.options.showMenuAbove) { + this.positionMenuAbove(); + } + + if (this.options.opened) { + if (this.options.preserveContext) { + this.options.opened(e); + } else { + this.options.opened.call(this, e); + } + } + + return this.dropdown.trigger('shown.gl.dropdown'); + } + + positionMenuAbove() { + const $menu = this.dropdown.find('.dropdown-menu'); + + $menu.addClass('dropdown-open-top'); + $menu.css('top', 'initial'); + $menu.css('bottom', '100%'); + } + + hidden(e) { + this.resetRows(); + this.removeArrowKeyEvent(); + const $input = this.dropdown.find('.dropdown-input-field'); + if (this.options.filterable) { + $input.blur(); + } + if (this.dropdown.find('.dropdown-toggle-page').length) { + $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + } + if (this.options.hidden) { + this.options.hidden.call(this, e); + } + return this.dropdown.trigger('hidden.gl.dropdown'); + } + + // Render the full menu + renderMenu(html) { + if (this.options.renderMenu) { + return this.options.renderMenu(html); + } + return $('<ul>').append(html); + } + + // Append the menu into the dropdown + appendMenu(html) { + return this.clearMenu().append(html); + } + + clearMenu() { + let selector = '.dropdown-content'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + if (this.options.containerSelector) { + selector = this.options.containerSelector; + } else { + selector = '.dropdown-page-one .dropdown-content'; + } + } + + return $(selector, this.dropdown).empty(); + } + + renderItem(data, group, index) { + let parent; + + if (this.dropdown && this.dropdown[0]) { + parent = this.dropdown[0].parentNode; + } + + return renderItem({ + instance: this, + options: { + ...this.options, + icon: this.icon, + highlight: this.highlight, + highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), + highlightTemplate: this.highlightTemplate.bind(this), + parent, + }, + data, + group, + index, + }); + } + + // eslint-disable-next-line class-methods-use-this + highlightTemplate(text, template) { + return `"<b>${escape(text)}</b>" ${template}`; + } + + // eslint-disable-next-line class-methods-use-this + highlightTextMatches(text, term) { + const occurrences = fuzzaldrinPlus.match(text, term); + const { indexOf } = []; + + return text + .split('') + .map((character, i) => { + if (indexOf.call(occurrences, i) !== -1) { + return `<b>${character}</b>`; + } + return character; + }) + .join(''); + } + + // eslint-disable-next-line class-methods-use-this + noResults() { + return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; + } + + rowClicked(el) { + let field; + let groupName; + let selectedIndex; + let selectedObject; + let isMarking; + const { fieldName } = this.options; + const isInput = $(this.el).is('input'); + if (this.renderedData) { + groupName = el.data('group'); + if (groupName) { + selectedIndex = el.data('index'); + selectedObject = this.renderedData[groupName][selectedIndex]; + } else { + selectedIndex = el.closest('li').index(); + this.selectedIndex = selectedIndex; + selectedObject = this.renderedData[selectedIndex]; + } + } + + if (this.options.vue) { + if (el.hasClass(ACTIVE_CLASS)) { + el.removeClass(ACTIVE_CLASS); + } else { + el.addClass(ACTIVE_CLASS); + } + + return [selectedObject]; + } + + field = []; + const 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, "\\'")}']`); + } + + if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { + return [selectedObject]; + } + + if (el.hasClass(ACTIVE_CLASS) && value !== 0) { + isMarking = false; + el.removeClass(ACTIVE_CLASS); + if (field && field.length) { + this.clearField(field, isInput); + } + } else if (el.hasClass(INDETERMINATE_CLASS)) { + isMarking = true; + el.addClass(ACTIVE_CLASS); + el.removeClass(INDETERMINATE_CLASS); + if (field && field.length && value == null) { + this.clearField(field, isInput); + } + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } + } else { + isMarking = true; + if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { + this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); + if (!isInput) { + this.dropdown + .parent() + .find(`input[name='${fieldName}']`) + .remove(); + } + } + if (field && field.length && value == null) { + this.clearField(field, isInput); + } + // Toggle active class for the tick mark + el.addClass(ACTIVE_CLASS); + if (value != null) { + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } else if (field && field.length) { + field.val(value).trigger('change'); + } + } + } + + return [selectedObject, isMarking]; + } + + focusTextInput() { + if (this.options.filterable) { + const initialScrollTop = $(window).scrollTop(); + + if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { + this.filterInput.focus(); + } + + if ($(window).scrollTop() < initialScrollTop) { + $(window).scrollTop(initialScrollTop); + } + } + } + + addInput(fieldName, value, selectedObject, single) { + // Create hidden input for form + if (single) { + $(`input[name="${fieldName}"]`).remove(); + } + + const $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 => { + $input.attr(`data-${attribute}`, selectedObject[attribute]); + }); + } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } + + this.dropdown.before($input).trigger('change'); + } + + selectRowAtIndex(index) { + // If we pass an option index + let selector; + if (typeof index !== 'undefined') { + selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; + } else { + selector = '.dropdown-content .is-focused'; + } + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + // simulate a click on the first link + const $el = $(selector, this.dropdown); + if ($el.length) { + const href = $el.attr('href'); + if (href && href !== '#') { + visitUrl(href); + } else { + $el.trigger('click'); + } + } + } + + addArrowKeyEvent() { + const ARROW_KEY_CODES = [38, 40]; + let selector = SELECTABLE_CLASSES; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + return $('body').on('keydown', e => { + let $listItems; + let PREV_INDEX; + const 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; + } + } + if (currentIndex !== PREV_INDEX) { + this.highlightRowAtIndex($listItems, currentIndex); + } + return false; + } + if (currentKeyCode === 13 && currentIndex !== -1) { + e.preventDefault(); + this.selectRowAtIndex(); + } + }); + } + + // eslint-disable-next-line class-methods-use-this + removeArrowKeyEvent() { + return $('body').off('keydown'); + } + + resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); + } + + highlightRowAtIndex($listItems, index) { + if (!$listItems) { + // eslint-disable-next-line no-param-reassign + $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 + const $listItem = $listItems.eq(index); + $listItem.find('a:first-child').addClass('is-focused'); + // Dropdown content scroll area + const $dropdownContent = $listItem.closest('.dropdown-content'); + const dropdownScrollTop = $dropdownContent.scrollTop(); + const dropdownContentHeight = $dropdownContent.outerHeight(); + const dropdownContentTop = $dropdownContent.prop('offsetTop'); + const dropdownContentBottom = dropdownContentTop + dropdownContentHeight; + // Get the offset bottom of the list item + const listItemHeight = $listItem.outerHeight(); + const listItemTop = $listItem.prop('offsetTop'); + const listItemBottom = listItemTop + listItemHeight; + if (!index) { + // Scroll the dropdown content to the top + $dropdownContent.scrollTop(0); + } else if (index === $listItems.length - 1) { + // Scroll the dropdown content to the bottom + $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); + } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { + // Scroll the dropdown content down + $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, + ); + } + } + + updateLabel(selected = null, el = null, instance = null) { + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } + + return $(this.el) + .find('.dropdown-toggle-text') + .text(toggleText); + } + + // eslint-disable-next-line class-methods-use-this + clearField(field, isInput) { + return isInput ? field.val('') : field.remove(); + } +} diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js new file mode 100644 index 00000000000..89ffb5f5f79 --- /dev/null +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js @@ -0,0 +1,135 @@ +/* eslint-disable consistent-return */ + +import $ from 'jquery'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { isObject } from '~/lib/utils/type_utility'; + +const BLUR_KEYCODES = [27, 40]; + +const HAS_VALUE_CLASS = 'has-value'; + +export class GitLabDropdownFilter { + constructor(input, options) { + let ref; + let timeout; + this.input = input; + this.options = options; + // eslint-disable-next-line no-cond-assign + this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; + const $inputContainer = this.input.parent(); + const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', e => { + // Clear click + e.preventDefault(); + e.stopPropagation(); + return this.input + .val('') + .trigger('input') + .focus(); + }); + // Key events + timeout = ''; + this.input + .on('keydown', e => { + const keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', () => { + 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); + // eslint-disable-next-line no-return-assign + return (timeout = setTimeout(() => { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query(this.input.val(), data => { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); + }); + }, 250)); + } + return this.filter(this.input.val()); + }); + } + + static shouldBlur(keyCode) { + return BLUR_KEYCODES.indexOf(keyCode) !== -1; + } + + filter(searchText) { + let group; + let results; + let tmp; + if (this.options.onFilter) { + this.options.onFilter(searchText); + } + const data = this.options.data(); + if (data != null && !this.options.filterByText) { + results = data; + if (searchText !== '') { + // When data is an array of objects therefore [object Array] e.g. + // [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ] + if (Array.isArray(data)) { + results = fuzzaldrinPlus.filter(data, searchText, { + key: this.options.keys, + }); + } + // If data is grouped therefore an [object Object]. e.g. + // { + // groupName1: [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ], + // groupName2: [ + // { prop: 'abc' }, + // { prop: 'def' } + // ] + // } + else if (isObject(data)) { + results = {}; + Object.keys(data).forEach(key => { + group = data[key]; + tmp = fuzzaldrinPlus.filter(group, searchText, { + key: this.options.keys, + }); + if (tmp.length) { + results[key] = tmp.map(item => item); + } + }); + } + } + return this.options.callback(results); + } + const elements = this.options.elements(); + if (searchText) { + // eslint-disable-next-line func-names + elements.each(function() { + const $el = $(this); + const matches = fuzzaldrinPlus.match($el.text().trim(), searchText); + if (!$el.is('.dropdown-header')) { + if (matches.length) { + return $el.show().removeClass('option-hidden'); + } + return $el.hide().addClass('option-hidden'); + } + }); + } else { + elements.show().removeClass('option-hidden'); + } + + elements + .parent() + .find('.dropdown-menu-empty-item') + .toggleClass('hidden', elements.is(':visible')); + } +} diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_input.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_input.js new file mode 100644 index 00000000000..d857071d05f --- /dev/null +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_input.js @@ -0,0 +1,44 @@ +export class GitLabDropdownInput { + constructor(input, options) { + this.input = input; + this.options = options; + this.fieldName = this.options.fieldName || 'field-name'; + const $inputContainer = this.input.parent(); + const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', e => { + // Clear click + e.preventDefault(); + e.stopPropagation(); + return this.input + .val('') + .trigger('input') + .focus(); + }); + + this.input + .on('keydown', e => { + const keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', e => { + let 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); + }); + } + + onInput(cb) { + this.cb = cb; + } +} diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js new file mode 100644 index 00000000000..1f6a2e1f646 --- /dev/null +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js @@ -0,0 +1,42 @@ +/* eslint-disable consistent-return */ + +import axios from '../lib/utils/axios_utils'; + +export class GitLabDropdownRemote { + constructor(dataEndpoint, options) { + this.dataEndpoint = dataEndpoint; + this.options = options; + } + + execute() { + if (typeof this.dataEndpoint === 'string') { + return this.fetchData(); + } else if (typeof this.dataEndpoint === 'function') { + if (this.options.beforeSend) { + this.options.beforeSend(); + } + return this.dataEndpoint('', data => { + // Fetch the data by calling the data function + if (this.options.success) { + this.options.success(data); + } + if (this.options.beforeSend) { + return this.options.beforeSend(); + } + }); + } + } + + fetchData() { + if (this.options.beforeSend) { + this.options.beforeSend(); + } + + // 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); + } + }); + } +} diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/index.js b/app/assets/javascripts/deprecated_jquery_dropdown/index.js index 3f2474b5c8c..90e7f15b5b7 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/index.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/index.js @@ -1,894 +1,5 @@ -/* eslint-disable max-classes-per-file, one-var, consistent-return */ - import $ from 'jquery'; -import { escape } from 'lodash'; -import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import axios from '../lib/utils/axios_utils'; -import { visitUrl } from '~/lib/utils/url_utility'; -import { isObject } from '~/lib/utils/type_utility'; -import renderItem from './render'; - -const BLUR_KEYCODES = [27, 40]; - -const HAS_VALUE_CLASS = 'has-value'; - -const LOADING_CLASS = 'is-loading'; - -const PAGE_TWO_CLASS = 'is-page-two'; - -const ACTIVE_CLASS = 'is-active'; - -const INDETERMINATE_CLASS = 'is-indeterminate'; - -let currentIndex = -1; - -const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; - -const SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`; - -const CURSOR_SELECT_SCROLL_PADDING = 5; - -const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)'; - -const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; - -class GitLabDropdownInput { - constructor(input, options) { - this.input = input; - this.options = options; - this.fieldName = this.options.fieldName || 'field-name'; - const $inputContainer = this.input.parent(); - const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', e => { - // Clear click - e.preventDefault(); - e.stopPropagation(); - return this.input - .val('') - .trigger('input') - .focus(); - }); - - this.input - .on('keydown', e => { - const keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', e => { - let 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); - }); - } - - onInput(cb) { - this.cb = cb; - } -} - -class GitLabDropdownFilter { - constructor(input, options) { - let ref, timeout; - this.input = input; - this.options = options; - // eslint-disable-next-line no-cond-assign - this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; - const $inputContainer = this.input.parent(); - const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', e => { - // Clear click - e.preventDefault(); - e.stopPropagation(); - return this.input - .val('') - .trigger('input') - .focus(); - }); - // Key events - timeout = ''; - this.input - .on('keydown', e => { - const keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', () => { - 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); - // eslint-disable-next-line no-return-assign - return (timeout = setTimeout(() => { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query(this.input.val(), data => { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }); - }, 250)); - } - return this.filter(this.input.val()); - }); - } - - static shouldBlur(keyCode) { - return BLUR_KEYCODES.indexOf(keyCode) !== -1; - } - - filter(searchText) { - let group, results, tmp; - if (this.options.onFilter) { - this.options.onFilter(searchText); - } - const data = this.options.data(); - if (data != null && !this.options.filterByText) { - results = data; - if (searchText !== '') { - // When data is an array of objects therefore [object Array] e.g. - // [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ] - if (Array.isArray(data)) { - results = fuzzaldrinPlus.filter(data, searchText, { - key: this.options.keys, - }); - } - // If data is grouped therefore an [object Object]. e.g. - // { - // groupName1: [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ], - // groupName2: [ - // { prop: 'abc' }, - // { prop: 'def' } - // ] - // } - else if (isObject(data)) { - results = {}; - Object.keys(data).forEach(key => { - group = data[key]; - tmp = fuzzaldrinPlus.filter(group, searchText, { - key: this.options.keys, - }); - if (tmp.length) { - results[key] = tmp.map(item => item); - } - }); - } - } - return this.options.callback(results); - } - const elements = this.options.elements(); - if (searchText) { - // eslint-disable-next-line func-names - elements.each(function() { - const $el = $(this); - const matches = fuzzaldrinPlus.match($el.text().trim(), searchText); - if (!$el.is('.dropdown-header')) { - if (matches.length) { - return $el.show().removeClass('option-hidden'); - } - return $el.hide().addClass('option-hidden'); - } - }); - } else { - elements.show().removeClass('option-hidden'); - } - - elements - .parent() - .find('.dropdown-menu-empty-item') - .toggleClass('hidden', elements.is(':visible')); - } -} - -class GitLabDropdownRemote { - constructor(dataEndpoint, options) { - this.dataEndpoint = dataEndpoint; - this.options = options; - } - - execute() { - if (typeof this.dataEndpoint === 'string') { - return this.fetchData(); - } else if (typeof this.dataEndpoint === 'function') { - if (this.options.beforeSend) { - this.options.beforeSend(); - } - return this.dataEndpoint('', data => { - // Fetch the data by calling the data function - if (this.options.success) { - this.options.success(data); - } - if (this.options.beforeSend) { - return this.options.beforeSend(); - } - }); - } - } - - fetchData() { - if (this.options.beforeSend) { - this.options.beforeSend(); - } - - // 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); - } - }); - } -} - -class GitLabDropdown { - constructor(el1, options) { - let selector, self; - this.el = el1; - this.options = options; - this.updateLabel = this.updateLabel.bind(this); - this.hidden = this.hidden.bind(this); - this.opened = this.opened.bind(this); - this.shouldPropagate = this.shouldPropagate.bind(this); - self = this; - 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 = Boolean(this.options.highlight); - this.icon = Boolean(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 - if (typeof this.filterInput === 'string') { - this.filterInput = this.getElement(this.filterInput); - } - const searchFields = this.options.search ? this.options.search.fields : []; - if (this.options.data) { - // If we provided data - // data could be an array of objects or a group of arrays - if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) { - this.fullData = this.options.data; - currentIndex = -1; - this.parseData(this.options.data); - this.focusTextInput(); - } else { - this.remote = new GitLabDropdownRemote(this.options.data, { - dataType: this.options.dataType, - beforeSend: this.toggleLoading.bind(this), - success: data => { - this.fullData = data; - this.parseData(this.fullData); - this.focusTextInput(); - - // Update dropdown position since remote data may have changed dropdown size - this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); - - if ( - this.options.filterable && - this.filter && - this.filter.input && - this.filter.input.val() && - this.filter.input.val().trim() !== '' - ) { - return this.filter.input.trigger('input'); - } - }, - instance: this, - }); - } - } - if (this.noFilterInput.length) { - this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); - this.plainInput.onInput(this.addInput.bind(this)); - } - // Init filterable - if (this.options.filterable) { - this.filter = new GitLabDropdownFilter(this.filterInput, { - elIsInput: $(this.el).is('input'), - filterInputBlur: this.filterInputBlur, - filterByText: this.options.filterByText, - onFilter: this.options.onFilter, - remote: this.options.filterRemote, - query: this.options.data, - keys: searchFields, - instance: this, - elements: () => { - selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - return $(selector, this.dropdown); - }, - data: () => this.fullData, - callback: data => { - this.parseData(data); - if (this.filterInput.val() !== '') { - selector = SELECTABLE_CLASSES; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - if ($(this.el).is('input')) { - currentIndex = -1; - } else { - $(selector, this.dropdown) - .first() - .find('a') - .addClass('is-focused'); - currentIndex = 0; - } - } - }, - }); - } - // 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', e => { - // Escape key - if (e.which === 27) { - return $('.dropdown-menu-close', this.dropdown).trigger('click'); - } - }); - this.dropdown.on('blur', 'a', e => { - let $dropdownMenu, $relatedTarget; - if (e.relatedTarget != null) { - $relatedTarget = $(e.relatedTarget); - $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); - if ($dropdownMenu.length === 0) { - return this.dropdown.removeClass('show'); - } - } - }); - if (this.dropdown.find('.dropdown-toggle-page').length) { - this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => { - e.preventDefault(); - e.stopPropagation(); - return this.togglePage(); - }); - } - if (this.options.selectable) { - selector = '.dropdown-content a'; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one .dropdown-content a'; - } - this.dropdown.on('click', selector, e => { - const $el = $(e.currentTarget); - const selected = self.rowClicked($el); - const selectedObj = selected ? selected[0] : null; - const 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); - } - - $el.trigger('blur'); - }); - } - } - - // Finds an element inside wrapper element - getElement(selector) { - return this.dropdown.find(selector); - } - - toggleLoading() { - return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); - } - - togglePage() { - const menu = $('.dropdown-menu', this.dropdown); - if (menu.hasClass(PAGE_TWO_CLASS)) { - if (this.remote) { - this.remote.execute(); - } - } - menu.toggleClass(PAGE_TWO_CLASS); - // Focus first visible input on active page - return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); - } - - parseData(data) { - let groupData, html; - this.renderedData = data; - if (this.options.filterable && data.length === 0) { - // render no matching results - html = [this.noResults()]; - } - // Handle array groups - else if (isObject(data)) { - html = []; - - Object.keys(data).forEach(name => { - groupData = data[name]; - html.push( - this.renderItem( - { - content: name, - type: 'header', - }, - name, - ), - ); - this.renderData(groupData, name).map(item => html.push(item)); - }); - } else { - // Render each row - html = this.renderData(data); - } - // Render the full menu - const fullHtml = this.renderMenu(html); - return this.appendMenu(fullHtml); - } - - renderData(data, group) { - return data.map((obj, index) => this.renderItem(obj, group || false, index)); - } - - shouldPropagate(e) { - let $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') - ) { - e.stopPropagation(); - - // This prevents automatic scrolling to the top - if ($target.closest('a').length) { - return false; - } - } - - return true; - } - } - - filteredFullData() { - return this.fullData.filter( - r => - typeof r === 'object' && - !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && - !Object.prototype.hasOwnProperty.call(r, 'header'), - ); - } - - opened(e) { - this.resetRows(); - this.addArrowKeyEvent(); - - const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); - const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); - const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open'); - const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); - - // Makes indeterminate items effective - if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { - this.parseData(this.fullData); - } - - // Process the data to make sure rendered data - // 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), - ); - } - - const contentHtml = $('.dropdown-content', this.dropdown).html(); - if (this.remote && contentHtml === '') { - this.remote.execute(); - } else { - this.focusTextInput(); - } - - if (this.options.showMenuAbove) { - this.positionMenuAbove(); - } - - if (this.options.opened) { - if (this.options.preserveContext) { - this.options.opened(e); - } else { - this.options.opened.call(this, e); - } - } - - return this.dropdown.trigger('shown.gl.dropdown'); - } - - positionMenuAbove() { - const $menu = this.dropdown.find('.dropdown-menu'); - - $menu.addClass('dropdown-open-top'); - $menu.css('top', 'initial'); - $menu.css('bottom', '100%'); - } - - hidden(e) { - this.resetRows(); - this.removeArrowKeyEvent(); - const $input = this.dropdown.find('.dropdown-input-field'); - if (this.options.filterable) { - $input.blur(); - } - if (this.dropdown.find('.dropdown-toggle-page').length) { - $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); - } - if (this.options.hidden) { - this.options.hidden.call(this, e); - } - return this.dropdown.trigger('hidden.gl.dropdown'); - } - - // Render the full menu - renderMenu(html) { - if (this.options.renderMenu) { - return this.options.renderMenu(html); - } - return $('<ul>').append(html); - } - - // Append the menu into the dropdown - appendMenu(html) { - return this.clearMenu().append(html); - } - - clearMenu() { - let selector = '.dropdown-content'; - if (this.dropdown.find('.dropdown-toggle-page').length) { - if (this.options.containerSelector) { - selector = this.options.containerSelector; - } else { - selector = '.dropdown-page-one .dropdown-content'; - } - } - - return $(selector, this.dropdown).empty(); - } - - renderItem(data, group, index) { - let parent; - - if (this.dropdown && this.dropdown[0]) { - parent = this.dropdown[0].parentNode; - } - - return renderItem({ - instance: this, - options: { - ...this.options, - icon: this.icon, - highlight: this.highlight, - highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), - highlightTemplate: this.highlightTemplate.bind(this), - parent, - }, - data, - group, - index, - }); - } - - // eslint-disable-next-line class-methods-use-this - highlightTemplate(text, template) { - return `"<b>${escape(text)}</b>" ${template}`; - } - - // eslint-disable-next-line class-methods-use-this - highlightTextMatches(text, term) { - const occurrences = fuzzaldrinPlus.match(text, term); - const { indexOf } = []; - - return text - .split('') - .map((character, i) => { - if (indexOf.call(occurrences, i) !== -1) { - return `<b>${character}</b>`; - } - return character; - }) - .join(''); - } - - // eslint-disable-next-line class-methods-use-this - noResults() { - return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; - } - - rowClicked(el) { - let field, groupName, selectedIndex, selectedObject, isMarking; - const { fieldName } = this.options; - const isInput = $(this.el).is('input'); - if (this.renderedData) { - groupName = el.data('group'); - if (groupName) { - selectedIndex = el.data('index'); - selectedObject = this.renderedData[groupName][selectedIndex]; - } else { - selectedIndex = el.closest('li').index(); - this.selectedIndex = selectedIndex; - selectedObject = this.renderedData[selectedIndex]; - } - } - - if (this.options.vue) { - if (el.hasClass(ACTIVE_CLASS)) { - el.removeClass(ACTIVE_CLASS); - } else { - el.addClass(ACTIVE_CLASS); - } - - return [selectedObject]; - } - - field = []; - const 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, "\\'")}']`); - } - - if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { - return [selectedObject]; - } - - if (el.hasClass(ACTIVE_CLASS) && value !== 0) { - isMarking = false; - el.removeClass(ACTIVE_CLASS); - if (field && field.length) { - this.clearField(field, isInput); - } - } else if (el.hasClass(INDETERMINATE_CLASS)) { - isMarking = true; - el.addClass(ACTIVE_CLASS); - el.removeClass(INDETERMINATE_CLASS); - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } - } else { - isMarking = true; - if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { - this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); - if (!isInput) { - this.dropdown - .parent() - .find(`input[name='${fieldName}']`) - .remove(); - } - } - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - // Toggle active class for the tick mark - el.addClass(ACTIVE_CLASS); - if (value != null) { - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } else if (field && field.length) { - field.val(value).trigger('change'); - } - } - } - - return [selectedObject, isMarking]; - } - - focusTextInput() { - if (this.options.filterable) { - const initialScrollTop = $(window).scrollTop(); - - if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { - this.filterInput.focus(); - } - - if ($(window).scrollTop() < initialScrollTop) { - $(window).scrollTop(initialScrollTop); - } - } - } - - addInput(fieldName, value, selectedObject, single) { - // Create hidden input for form - if (single) { - $(`input[name="${fieldName}"]`).remove(); - } - - const $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 => { - $input.attr(`data-${attribute}`, selectedObject[attribute]); - }); - } - - if (this.options.inputMeta) { - $input.attr('data-meta', selectedObject[this.options.inputMeta]); - } - - this.dropdown.before($input).trigger('change'); - } - - selectRowAtIndex(index) { - // If we pass an option index - let selector; - if (typeof index !== 'undefined') { - selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; - } else { - selector = '.dropdown-content .is-focused'; - } - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - // simulate a click on the first link - const $el = $(selector, this.dropdown); - if ($el.length) { - const href = $el.attr('href'); - if (href && href !== '#') { - visitUrl(href); - } else { - $el.trigger('click'); - } - } - } - - addArrowKeyEvent() { - const ARROW_KEY_CODES = [38, 40]; - let selector = SELECTABLE_CLASSES; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - return $('body').on('keydown', e => { - let $listItems, PREV_INDEX; - const 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; - } - } - if (currentIndex !== PREV_INDEX) { - this.highlightRowAtIndex($listItems, currentIndex); - } - return false; - } - if (currentKeyCode === 13 && currentIndex !== -1) { - e.preventDefault(); - this.selectRowAtIndex(); - } - }); - } - - // eslint-disable-next-line class-methods-use-this - removeArrowKeyEvent() { - return $('body').off('keydown'); - } - - resetRows() { - currentIndex = -1; - $('.is-focused', this.dropdown).removeClass('is-focused'); - } - - highlightRowAtIndex($listItems, index) { - if (!$listItems) { - // eslint-disable-next-line no-param-reassign - $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 - const $listItem = $listItems.eq(index); - $listItem.find('a:first-child').addClass('is-focused'); - // Dropdown content scroll area - const $dropdownContent = $listItem.closest('.dropdown-content'); - const dropdownScrollTop = $dropdownContent.scrollTop(); - const dropdownContentHeight = $dropdownContent.outerHeight(); - const dropdownContentTop = $dropdownContent.prop('offsetTop'); - const dropdownContentBottom = dropdownContentTop + dropdownContentHeight; - // Get the offset bottom of the list item - const listItemHeight = $listItem.outerHeight(); - const listItemTop = $listItem.prop('offsetTop'); - const listItemBottom = listItemTop + listItemHeight; - if (!index) { - // Scroll the dropdown content to the top - $dropdownContent.scrollTop(0); - } else if (index === $listItems.length - 1) { - // Scroll the dropdown content to the bottom - $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); - } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { - // Scroll the dropdown content down - $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, - ); - } - } - - updateLabel(selected = null, el = null, instance = null) { - let toggleText = this.options.toggleLabel(selected, el, instance); - if (this.options.updateLabel) { - // Option to override the dropdown label text - toggleText = this.options.updateLabel; - } - - return $(this.el) - .find('.dropdown-toggle-text') - .text(toggleText); - } - - // eslint-disable-next-line class-methods-use-this - clearField(field, isInput) { - return isInput ? field.val('') : field.remove(); - } -} +import { GitLabDropdown } from './gl_dropdown'; export default function initDeprecatedJQueryDropdown($el, opts) { // eslint-disable-next-line func-names diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 2e8b3d764ca..b6d5eb7e5c0 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -238,6 +238,7 @@ class Clusters::ClustersController < Clusters::BaseController :environment_scope, :managed, provider_aws_attributes: [ + :kubernetes_version, :key_name, :role_arn, :region, diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 2a8bc823931..9ad6bf4095a 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -21,7 +21,7 @@ class Projects::PagesController < Projects::ApplicationController format.html do redirect_to project_pages_path(@project), status: :found, - notice: 'Pages were removed' + notice: 'Pages were scheduled for removal' end end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index b70d0b7a06a..37da29b32ff 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -110,7 +110,9 @@ class MergeRequestsFinder < IssuableFinder .or(table[:title].matches('WIP %')) .or(table[:title].matches('[WIP]%')) - return items unless Feature.enabled?(:merge_request_draft_filter) + # Let's keep this FF around until https://gitlab.com/gitlab-org/gitlab/-/issues/232999 + # is implemented + return items unless Feature.enabled?(:merge_request_draft_filter, default_enabled: true) items .or(table[:title].matches('Draft - %')) diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index 86869361ed8..35e8b751b3d 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -37,7 +37,7 @@ module Clusters greater_than: 0 } - validates :key_name, :region, :instance_type, :security_group_id, length: { in: 1..255 } + validates :kubernetes_version, :key_name, :region, :instance_type, :security_group_id, length: { in: 1..255 } validates :subnet_ids, presence: true def nullify_credentials diff --git a/app/services/clusters/aws/provision_service.rb b/app/services/clusters/aws/provision_service.rb index 109e4c04a9c..b454a7a5f59 100644 --- a/app/services/clusters/aws/provision_service.rb +++ b/app/services/clusters/aws/provision_service.rb @@ -63,6 +63,7 @@ module Clusters [ parameter('ClusterName', provider.cluster.name), parameter('ClusterRole', provider.role_arn), + parameter('KubernetesVersion', provider.kubernetes_version), parameter('ClusterControlPlaneSecurityGroup', provider.security_group_id), parameter('VpcId', provider.vpc_id), parameter('Subnets', provider.subnet_ids.join(',')), diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb index 7408943e78c..1ac12af9a2a 100644 --- a/app/services/pages/delete_service.rb +++ b/app/services/pages/delete_service.rb @@ -3,8 +3,11 @@ module Pages class DeleteService < BaseService def execute - project.remove_pages - project.pages_domains.destroy_all # rubocop: disable Cop/DestroyAll + if Feature.enabled?(:async_pages_removal, project) + PagesRemoveWorker.perform_async(project.id) + else + PagesRemoveWorker.new.perform(project.id) + end end end end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e16032cdc50..57f09fbfc2f 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1358,7 +1358,7 @@ :tags: [] - :name: flush_counter_increments :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1450,7 +1450,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: merge_request_mergeability_check :feature_category: :source_code_management @@ -1532,6 +1532,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: pages_remove + :feature_category: :pages + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: pages_update_configuration :feature_category: :pages :has_external_dependencies: diff --git a/app/workers/pages_remove_worker.rb b/app/workers/pages_remove_worker.rb new file mode 100644 index 00000000000..b83168fd7bd --- /dev/null +++ b/app/workers/pages_remove_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class PagesRemoveWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + sidekiq_options retry: 3 + feature_category :pages + loggable_arguments 0 + + def perform(project_id) + project = Project.find_by_id(project_id) + return unless project + + project.remove_pages + project.pages_domains.delete_all + end +end diff --git a/changelogs/unreleased/231463-gitlab-ci-aws-eks-integration-kubernetes-version-update.yml b/changelogs/unreleased/231463-gitlab-ci-aws-eks-integration-kubernetes-version-update.yml new file mode 100644 index 00000000000..054eea85a6d --- /dev/null +++ b/changelogs/unreleased/231463-gitlab-ci-aws-eks-integration-kubernetes-version-update.yml @@ -0,0 +1,5 @@ +--- +title: Update EKS Kubernetes versions +merge_request: 38644 +author: +type: fixed diff --git a/changelogs/unreleased/djensen-finish-renaming-analytics-workspace.yml b/changelogs/unreleased/djensen-finish-renaming-analytics-workspace.yml new file mode 100644 index 00000000000..ad84a3ed4c5 --- /dev/null +++ b/changelogs/unreleased/djensen-finish-renaming-analytics-workspace.yml @@ -0,0 +1,5 @@ +--- +title: Re-name Analytics Workspace as instance-level analytics +merge_request: 40436 +author: +type: changed diff --git a/changelogs/unreleased/id-enable-merge-request-draft-filter.yml b/changelogs/unreleased/id-enable-merge-request-draft-filter.yml new file mode 100644 index 00000000000..88d29ee7733 --- /dev/null +++ b/changelogs/unreleased/id-enable-merge-request-draft-filter.yml @@ -0,0 +1,5 @@ +--- +title: Include draft merge request into filter response +merge_request: 40376 +author: +type: changed diff --git a/config/feature_flags/development/async_pages_removal.yml b/config/feature_flags/development/async_pages_removal.yml new file mode 100644 index 00000000000..2377f7764cc --- /dev/null +++ b/config/feature_flags/development/async_pages_removal.yml @@ -0,0 +1,7 @@ +--- +name: async_pages_removal +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38313 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/239318 +group: team::Scalability +type: development +default_enabled: false
\ No newline at end of file diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index cda830de8f6..2e04522c824 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -178,6 +178,8 @@ - 1 - - pages_domain_verification - 1 +- - pages_remove + - 1 - - pages_update_configuration - 1 - - personal_access_tokens diff --git a/db/migrate/20200818052219_add_kubernetes_version_to_cluster_providers_aws.rb b/db/migrate/20200818052219_add_kubernetes_version_to_cluster_providers_aws.rb new file mode 100644 index 00000000000..fa3d5210e91 --- /dev/null +++ b/db/migrate/20200818052219_add_kubernetes_version_to_cluster_providers_aws.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# See https://docs.gitlab.com/ee/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddKubernetesVersionToClusterProvidersAws < ActiveRecord::Migration[6.0] + # Uncomment the following include if you require helper functions: + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + unless column_exists?(:cluster_providers_aws, :kubernetes_version) + add_column :cluster_providers_aws, :kubernetes_version, :text, null: false, default: '1.14' + end + + add_text_limit :cluster_providers_aws, :kubernetes_version, 30 + end + + def down + if column_exists?(:cluster_providers_aws, :kubernetes_version) + remove_column :cluster_providers_aws, :kubernetes_version + end + end +end diff --git a/db/schema_migrations/20200818052219 b/db/schema_migrations/20200818052219 new file mode 100644 index 00000000000..1df09a53ca4 --- /dev/null +++ b/db/schema_migrations/20200818052219 @@ -0,0 +1 @@ +94b494b5f8e351cf453699debf03aa28f8a9136c829cb7410c90590b5106cdd5
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index bec2f11356c..8d71b8d94fc 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10593,7 +10593,9 @@ CREATE TABLE public.cluster_providers_aws ( encrypted_secret_access_key_iv character varying(255), encrypted_secret_access_key text, session_token text, - status_reason text + status_reason text, + kubernetes_version text DEFAULT '1.14'::text NOT NULL, + CONSTRAINT check_f1f42cd85e CHECK ((char_length(kubernetes_version) <= 30)) ); CREATE SEQUENCE public.cluster_providers_aws_id_seq diff --git a/doc/user/analytics/index.md b/doc/user/analytics/index.md index e12ec320543..044b9eb3e64 100644 --- a/doc/user/analytics/index.md +++ b/doc/user/analytics/index.md @@ -6,15 +6,15 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Analytics -## Analytics workspace +## Instance-level analytics > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12077) in GitLab 12.2. -The Analytics workspace will make it possible to aggregate analytics across +Instance-level analytics make it possible to aggregate analytics across GitLab, so that users can view information across multiple projects and groups in one place. -To access the Analytics workspace, click on **More > Analytics** in the top navigation bar. +[Learn more about instance-level analytics](../admin_area/analytics/index.md). ## Group-level analytics diff --git a/lib/tasks/gitlab/sidekiq.rake b/lib/tasks/gitlab/sidekiq.rake index eb3de195626..6f00db42d78 100644 --- a/lib/tasks/gitlab/sidekiq.rake +++ b/lib/tasks/gitlab/sidekiq.rake @@ -5,7 +5,7 @@ return if Rails.env.production? namespace :gitlab do namespace :sidekiq do def write_yaml(path, banner, object) - File.write(path, banner + YAML.dump(object)) + File.write(path, banner + YAML.dump(object).gsub(/ *$/m, '')) end namespace :all_queues_yml do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 24b6ccc509b..f4181b68b0d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2834,9 +2834,6 @@ msgstr "" msgid "An error occurred while saving assignees" msgstr "" -msgid "An error occurred while saving the approval settings" -msgstr "" - msgid "An error occurred while saving the template. Please check if the template exists." msgstr "" @@ -21124,6 +21121,9 @@ msgstr "" msgid "Rook" msgstr "" +msgid "Rule name is already taken." +msgstr "" + msgid "Rules that define what git pushes are accepted for a project in this group. All newly created projects in this group will use these settings." msgstr "" @@ -24893,10 +24893,10 @@ msgstr "" msgid "There was an error when unsubscribing from this label." msgstr "" -msgid "There was an error while fetching the chart data." +msgid "There was an error while fetching the chart data. Please refresh the page to try again." msgstr "" -msgid "There was an error while fetching the table data." +msgid "There was an error while fetching the table data. Please refresh the page to try again." msgstr "" msgid "There was an error while fetching value stream analytics %{requestTypeName} data." diff --git a/package.json b/package.json index f5bcb056c7d..5ea9919883b 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.5", "@gitlab/svgs": "1.161.0", - "@gitlab/ui": "20.8.0", + "@gitlab/ui": "20.9.1", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-1", "@sentry/browser": "^5.10.2", diff --git a/rubocop/cop/rspec/timecop_freeze.rb b/rubocop/cop/rspec/timecop_freeze.rb new file mode 100644 index 00000000000..508b5df7c7f --- /dev/null +++ b/rubocop/cop/rspec/timecop_freeze.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module RSpec + # This cop checks for `Timecop.freeze` usage in specs. + # + # @example + # + # # bad + # Timecop.freeze(Time.current) { example.run } + # + # # good + # freeze_time(Time.current) { example.run } + # + class TimecopFreeze < RuboCop::Cop::Cop + include MatchRange + MESSAGE = 'Do not use `Timecop.freeze`, use `freeze_time` instead. ' \ + 'See https://gitlab.com/gitlab-org/gitlab/-/issues/214432 for more info.' + + def_node_matcher :timecop_freeze?, <<~PATTERN + (send (const nil? :Timecop) :freeze ?_) + PATTERN + + def on_send(node) + return unless timecop_freeze?(node) + + add_offense(node, location: :expression, message: MESSAGE) + end + + def autocorrect(node) + -> (corrector) do + each_match_range(node.source_range, /^(Timecop\.freeze)/) do |match_range| + corrector.replace(match_range, 'freeze_time') + end + end + end + end + end + end +end diff --git a/spec/factories/clusters/providers/aws.rb b/spec/factories/clusters/providers/aws.rb index 2c54300e606..497181de89a 100644 --- a/spec/factories/clusters/providers/aws.rb +++ b/spec/factories/clusters/providers/aws.rb @@ -4,6 +4,7 @@ FactoryBot.define do factory :cluster_provider_aws, class: 'Clusters::Providers::Aws' do association :cluster, platform_type: :kubernetes, provider_type: :aws + kubernetes_version { '1.16' } role_arn { 'arn:aws:iam::123456789012:role/role-name' } vpc_id { 'vpc-00000000000000000' } subnet_ids { %w(subnet-00000000000000000 subnet-11111111111111111) } diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index e1ace817c72..243579ee2f7 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -380,14 +380,14 @@ RSpec.shared_examples 'pages settings editing' do expect(project).to be_pages_deployed end - it 'removes the pages' do + it 'removes the pages', :sidekiq_inline do visit project_pages_path(project) expect(page).to have_link('Remove pages') accept_confirm { click_link 'Remove pages' } - expect(page).to have_content('Pages were removed') + expect(page).to have_content('Pages were scheduled for removal') expect(project.reload.pages_deployed?).to be_falsey end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 3577498c3b4..970500985ae 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -159,7 +159,7 @@ RSpec.describe 'Project' do describe 'remove forked relationship', :js do let(:user) { create(:user) } - let(:project) { fork_project(create(:project, :public), user, namespace_id: user.namespace) } + let(:project) { fork_project(create(:project, :public), user, namespace: user.namespace) } before do sign_in user diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index b97d4dbf355..82fa7cf8d0a 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -48,7 +48,7 @@ describe('Application Row', () => { describe('Install button', () => { const button = () => wrapper.find('.js-cluster-application-install-button'); const checkButtonState = (label, loading, disabled) => { - expect(button().props('label')).toEqual(label); + expect(button().text()).toEqual(label); expect(button().props('loading')).toEqual(loading); expect(button().props('disabled')).toEqual(disabled); }; @@ -56,7 +56,7 @@ describe('Application Row', () => { it('has indeterminate state on page load', () => { mountComponent({ status: null }); - expect(button().props('label')).toBeUndefined(); + expect(button().text()).toBe(''); }); it('has install button', () => { @@ -225,7 +225,7 @@ describe('Application Row', () => { mountComponent({ updateAvailable: true }); expect(button().exists()).toBe(true); - expect(button().props('label')).toContain('Update'); + expect(button().text()).toContain('Update'); }); it('has enabled "Retry update" when update process fails', () => { @@ -235,14 +235,14 @@ describe('Application Row', () => { }); expect(button().exists()).toBe(true); - expect(button().props('label')).toContain('Retry update'); + expect(button().text()).toContain('Retry update'); }); it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => { mountComponent({ status: APPLICATION_STATUS.UPDATING }); expect(button().exists()).toBe(true); - expect(button().props('label')).toContain('Updating'); + expect(button().text()).toContain('Updating'); }); it('clicking update button emits event', () => { diff --git a/spec/frontend/clusters/components/uninstall_application_button_spec.js b/spec/frontend/clusters/components/uninstall_application_button_spec.js index 9f9397d4d41..387e2188572 100644 --- a/spec/frontend/clusters/components/uninstall_application_button_spec.js +++ b/spec/frontend/clusters/components/uninstall_application_button_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS } from '~/clusters/constants'; const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS; @@ -19,14 +19,21 @@ describe('UninstallApplicationButton', () => { }); describe.each` - status | loading | disabled | label + status | loading | disabled | text ${INSTALLED} | ${false} | ${false} | ${'Uninstall'} ${UPDATING} | ${false} | ${true} | ${'Uninstall'} ${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'} - `('when app status is $status', ({ loading, disabled, status, label }) => { - it(`renders a loading=${loading}, disabled=${disabled} button with label="${label}"`, () => { + `('when app status is $status', ({ loading, disabled, status, text }) => { + beforeAll(() => { createComponent({ status }); - expect(wrapper.find(LoadingButton).props()).toMatchObject({ loading, disabled, label }); + }); + + it(`renders a button with loading=${loading} and disabled=${disabled}`, () => { + expect(wrapper.find(GlButton).props()).toMatchObject({ loading, disabled }); + }); + + it(`renders a button with text="${text}"`, () => { + expect(wrapper.find(GlButton).text()).toBe(text); }); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index 34d9ee733c4..6d0f8503103 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -147,6 +147,7 @@ describe('EksClusterConfigurationForm', () => { initialState: { clusterName: 'cluster name', environmentScope: '*', + kubernetesVersion: '1.16', selectedRegion: 'region', selectedRole: 'role', selectedKeyPair: 'key pair', diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index 882a4a002bd..ed753888790 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -47,7 +47,7 @@ describe('EKS Cluster Store Actions', () => { beforeEach(() => { clusterName = 'my cluster'; environmentScope = 'production'; - kubernetesVersion = '11.1'; + kubernetesVersion = '1.16'; region = 'regions-1'; vpc = 'vpc-1'; subnet = 'subnet-1'; @@ -180,6 +180,7 @@ describe('EKS Cluster Store Actions', () => { environment_scope: environmentScope, managed: gitlabManagedCluster, provider_aws_attributes: { + kubernetes_version: kubernetesVersion, region, vpc_id: vpc, subnet_ids: subnet, diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index fef5e018231..5629036694c 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Backup::Repository do end end - describe '#dump' do + describe '#dump', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241251' do before do allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys) end diff --git a/spec/requests/api/pages/pages_spec.rb b/spec/requests/api/pages/pages_spec.rb index 53e732928ff..f4c6de00e40 100644 --- a/spec/requests/api/pages/pages_spec.rb +++ b/spec/requests/api/pages/pages_spec.rb @@ -39,7 +39,9 @@ RSpec.describe API::Pages do expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) - delete api("/projects/#{project.id}/pages", admin ) + Sidekiq::Testing.inline! do + delete api("/projects/#{project.id}/pages", admin ) + end expect(project.reload.pages_metadatum.deployed?).to be(false) end diff --git a/spec/rubocop/cop/rspec/timecop_freeze_spec.rb b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb new file mode 100644 index 00000000000..3809431a2fc --- /dev/null +++ b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/rspec/timecop_freeze' + +RSpec.describe RuboCop::Cop::RSpec::TimecopFreeze, type: :rubocop do + include CopHelper + + subject(:cop) { described_class.new } + + context 'when calling Timecop.freeze' do + let(:source) do + <<~SRC + Timecop.freeze(Time.current) { example.run } + SRC + end + + let(:corrected_source) do + <<~SRC + freeze_time(Time.current) { example.run } + SRC + end + + it 'registers an offence' do + inspect_source(source) + + expect(cop.offenses.size).to eq(1) + end + + it 'can autocorrect the source' do + expect(autocorrect_source(source)).to eq(corrected_source) + end + end + + context 'when calling a different method on Timecop' do + let(:source) do + <<~SRC + Timecop.travel(Time.current) + SRC + end + + it 'does not register an offence' do + inspect_source(source) + + expect(cop.offenses).to be_empty + end + end +end diff --git a/spec/services/clusters/aws/provision_service_spec.rb b/spec/services/clusters/aws/provision_service_spec.rb index 529e1d26575..52612e5ac40 100644 --- a/spec/services/clusters/aws/provision_service_spec.rb +++ b/spec/services/clusters/aws/provision_service_spec.rb @@ -22,6 +22,7 @@ RSpec.describe Clusters::Aws::ProvisionService do [ { parameter_key: 'ClusterName', parameter_value: provider.cluster.name }, { parameter_key: 'ClusterRole', parameter_value: provider.role_arn }, + { parameter_key: 'KubernetesVersion', parameter_value: provider.kubernetes_version }, { parameter_key: 'ClusterControlPlaneSecurityGroup', parameter_value: provider.security_group_id }, { parameter_key: 'VpcId', parameter_value: provider.vpc_id }, { parameter_key: 'Subnets', parameter_value: provider.subnet_ids.join(',') }, diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb index 7f22af8bfc6..db25bb766c9 100644 --- a/spec/services/git/branch_hooks_service_spec.rb +++ b/spec/services/git/branch_hooks_service_spec.rb @@ -348,7 +348,7 @@ RSpec.describe Git::BranchHooksService do context 'when the project is forked', :sidekiq_might_not_need_inline do let(:upstream_project) { project } - let(:forked_project) { fork_project(upstream_project, user, repository: true) } + let(:forked_project) { fork_project(upstream_project, user, repository: true, using_service: true) } let!(:forked_service) do described_class.new(forked_project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }) diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index f99be26927d..20ed57c6e51 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -88,6 +88,10 @@ RSpec.describe MergeRequests::BuildService do let(:source_project) { fork_project(project, user) } let(:merge_request) { described_class.new(project, user, mr_params).execute } + before do + project.add_reporter(user) + end + it 'assigns force_remove_source_branch' do expect(merge_request.force_remove_source_branch?).to be_truthy end diff --git a/spec/services/pages/delete_services_spec.rb b/spec/services/pages/delete_services_spec.rb index f6d4694b4dd..661ccb36ba9 100644 --- a/spec/services/pages/delete_services_spec.rb +++ b/spec/services/pages/delete_services_spec.rb @@ -3,25 +3,45 @@ require 'spec_helper' RSpec.describe Pages::DeleteService do - let_it_be(:project) { create(:project, path: "my.project")} - let_it_be(:admin) { create(:admin) } - let_it_be(:domain) { create(:pages_domain, project: project) } - let_it_be(:service) { described_class.new(project, admin)} + shared_examples 'remove pages' do + let_it_be(:project) { create(:project, path: "my.project")} + let_it_be(:admin) { create(:admin) } + let_it_be(:domain) { create(:pages_domain, project: project) } + let_it_be(:service) { described_class.new(project, admin)} - it 'deletes published pages' do - expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true - expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) + it 'deletes published pages' do + expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true + expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) - service.execute + Sidekiq::Testing.inline! { service.execute } - expect(project.reload.pages_metadatum.deployed?).to be(false) + expect(project.reload.pages_metadatum.deployed?).to be(false) + end + + it 'deletes all domains' do + expect(project.pages_domains.count).to be 1 + + Sidekiq::Testing.inline! { service.execute } + + expect(project.reload.pages_domains.count).to be 0 + end end - it 'deletes all domains' do - expect(project.pages_domains.count).to be 1 + context 'with feature flag disabled' do + before do + stub_feature_flags(async_pages_removal: false) + + expect(PagesRemoveWorker).not_to receive(:perform_async) + end + + it_behaves_like 'remove pages' + end - service.execute + context 'with feature flag enabled' do + before do + expect(PagesRemoveWorker).to receive(:perform_async).and_call_original + end - expect(project.reload.pages_domains.count).to be 0 + it_behaves_like 'remove pages' end end diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 925c2ff5d88..166a2dae55b 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Projects::ForkService do it 'flushes the forks count cache of the source project', :clean_gitlab_redis_cache do expect(from_project.forks_count).to be_zero - fork_project(from_project, to_user) + fork_project(from_project, to_user, using_service: true) BatchLoader::Executor.clear_current expect(from_project.forks_count).to eq(1) @@ -40,7 +40,7 @@ RSpec.describe Projects::ForkService do @guest = create(:user) @from_project.add_user(@guest, :guest) end - subject { fork_project(@from_project, @guest) } + subject { fork_project(@from_project, @guest, using_service: true) } it { is_expected.not_to be_persisted } it { expect(subject.errors[:forked_from_project_id]).to eq(['is forbidden']) } @@ -56,7 +56,7 @@ RSpec.describe Projects::ForkService do end describe "successfully creates project in the user namespace" do - let(:to_project) { fork_project(@from_project, @to_user, namespace: @to_user.namespace) } + let(:to_project) { fork_project(@from_project, @to_user, namespace: @to_user.namespace, using_service: true) } it { expect(to_project).to be_persisted } it { expect(to_project.errors).to be_empty } @@ -92,21 +92,21 @@ RSpec.describe Projects::ForkService do end it 'imports the repository of the forked project', :sidekiq_might_not_need_inline do - to_project = fork_project(@from_project, @to_user, repository: true) + to_project = fork_project(@from_project, @to_user, repository: true, using_service: true) expect(to_project.empty_repo?).to be_falsy end end context 'creating a fork of a fork' do - let(:from_forked_project) { fork_project(@from_project, @to_user) } + let(:from_forked_project) { fork_project(@from_project, @to_user, using_service: true) } let(:other_namespace) do group = create(:group) group.add_owner(@to_user) group end - let(:to_project) { fork_project(from_forked_project, @to_user, namespace: other_namespace) } + let(:to_project) { fork_project(from_forked_project, @to_user, namespace: other_namespace, using_service: true) } it 'sets the root of the network to the root project' do expect(to_project.fork_network.root_project).to eq(@from_project) @@ -126,7 +126,7 @@ RSpec.describe Projects::ForkService do context 'project already exists' do it "fails due to validation, not transaction failure" do @existing_project = create(:project, :repository, creator_id: @to_user.id, name: @from_project.name, namespace: @to_namespace) - @to_project = fork_project(@from_project, @to_user, namespace: @to_namespace) + @to_project = fork_project(@from_project, @to_user, namespace: @to_namespace, using_service: true) expect(@existing_project).to be_persisted expect(@to_project).not_to be_persisted @@ -137,7 +137,7 @@ RSpec.describe Projects::ForkService do context 'repository in legacy storage already exists' do let(:fake_repo_path) { File.join(TestEnv.repos_path, @to_user.namespace.full_path, "#{@from_project.path}.git") } - let(:params) { { namespace: @to_user.namespace } } + let(:params) { { namespace: @to_user.namespace, using_service: true } } before do stub_application_setting(hashed_storage_enabled: false) @@ -169,13 +169,13 @@ RSpec.describe Projects::ForkService do context 'GitLab CI is enabled' do it "forks and enables CI for fork" do @from_project.enable_ci - @to_project = fork_project(@from_project, @to_user) + @to_project = fork_project(@from_project, @to_user, using_service: true) expect(@to_project.builds_enabled?).to be_truthy end end context "CI/CD settings" do - let(:to_project) { fork_project(@from_project, @to_user) } + let(:to_project) { fork_project(@from_project, @to_user, using_service: true) } context "when origin has git depth specified" do before do @@ -206,7 +206,7 @@ RSpec.describe Projects::ForkService do end it "creates fork with lowest level" do - forked_project = fork_project(@from_project, @to_user) + forked_project = fork_project(@from_project, @to_user, using_service: true) expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) end @@ -218,7 +218,7 @@ RSpec.describe Projects::ForkService do end it "creates fork with private visibility levels" do - forked_project = fork_project(@from_project, @to_user) + forked_project = fork_project(@from_project, @to_user, using_service: true) expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) end @@ -232,7 +232,7 @@ RSpec.describe Projects::ForkService do end it 'fails' do - to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace) + to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace, using_service: true) expect(to_project.errors[:forked_from_project_id]).to eq(['is forbidden']) end @@ -253,7 +253,7 @@ RSpec.describe Projects::ForkService do @group.add_user(@developer, GroupMember::DEVELOPER) @project.add_user(@developer, :developer) @project.add_user(@group_owner, :developer) - @opts = { namespace: @group } + @opts = { namespace: @group, using_service: true } end context 'fork project for group' do @@ -299,7 +299,7 @@ RSpec.describe Projects::ForkService do group_owner = create(:user) private_group.add_owner(group_owner) - forked_project = fork_project(public_project, group_owner, namespace: private_group) + forked_project = fork_project(public_project, group_owner, namespace: private_group, using_service: true) expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) end @@ -310,7 +310,7 @@ RSpec.describe Projects::ForkService do context 'when a project is already forked' do it 'creates a new poolresository after the project is moved to a new shard' do project = create(:project, :public, :repository) - fork_before_move = fork_project(project) + fork_before_move = fork_project(project, nil, using_service: true) # Stub everything required to move a project to a Gitaly shard that does not exist allow(Gitlab::GitalyClient).to receive(:filesystem_id).with('default').and_call_original @@ -329,7 +329,7 @@ RSpec.describe Projects::ForkService do destination_storage_name: 'test_second_storage' ) Projects::UpdateRepositoryStorageService.new(storage_move).execute - fork_after_move = fork_project(project.reload) + fork_after_move = fork_project(project.reload, nil, using_service: true) pool_repository_before_move = PoolRepository.joins(:shard) .find_by(source_project: project, shards: { name: 'default' }) pool_repository_after_move = PoolRepository.joins(:shard) @@ -350,7 +350,7 @@ RSpec.describe Projects::ForkService do context 'when no pool exists' do it 'creates a new object pool' do - forked_project = fork_project(fork_from_project, forker) + forked_project = fork_project(fork_from_project, forker, using_service: true) expect(forked_project.pool_repository).to eq(fork_from_project.pool_repository) end @@ -360,7 +360,7 @@ RSpec.describe Projects::ForkService do let!(:pool_repository) { create(:pool_repository, source_project: fork_from_project) } it 'joins the object pool' do - forked_project = fork_project(fork_from_project, forker) + forked_project = fork_project(fork_from_project, forker, using_service: true) expect(forked_project.pool_repository).to eq(fork_from_project.pool_repository) end diff --git a/spec/support/helpers/project_forks_helper.rb b/spec/support/helpers/project_forks_helper.rb index a32e39e52c8..4b4285f251e 100644 --- a/spec/support/helpers/project_forks_helper.rb +++ b/spec/support/helpers/project_forks_helper.rb @@ -17,14 +17,26 @@ module ProjectForksHelper project.add_developer(user) end - unless params[:namespace] || params[:namespace_id] + unless params[:namespace] params[:namespace] = create(:group) params[:namespace].add_owner(user) end + namespace = params[:namespace] + create_repository = params.delete(:repository) + + unless params[:target_project] || params[:using_service] + target_level = [project.visibility_level, namespace.visibility_level].min + visibility_level = Gitlab::VisibilityLevel.closest_allowed_level(target_level) + + params[:target_project] = + create(:project, + (:repository if create_repository), + visibility_level: visibility_level, creator: user, namespace: namespace) + end + service = Projects::ForkService.new(project, user, params) - create_repository = params.delete(:repository) # Avoid creating a repository unless create_repository allow(RepositoryForkWorker).to receive(:perform_async).and_return(true) diff --git a/spec/workers/pages_remove_worker_spec.rb b/spec/workers/pages_remove_worker_spec.rb new file mode 100644 index 00000000000..638e87043f2 --- /dev/null +++ b/spec/workers/pages_remove_worker_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe PagesRemoveWorker do + let_it_be(:project) { create(:project, path: "my.project")} + let_it_be(:domain) { create(:pages_domain, project: project) } + subject { described_class.new.perform(project.id) } + + it 'deletes published pages' do + expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true + expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) + + subject + + expect(project.reload.pages_metadatum.deployed?).to be(false) + end + + it 'deletes all domains' do + expect(project.pages_domains.count).to be 1 + + subject + + expect(project.reload.pages_domains.count).to be 0 + end +end diff --git a/vendor/aws/cloudformation/eks_cluster.yaml b/vendor/aws/cloudformation/eks_cluster.yaml index c32f54d66dc..c3f9c73cd1f 100644 --- a/vendor/aws/cloudformation/eks_cluster.yaml +++ b/vendor/aws/cloudformation/eks_cluster.yaml @@ -7,11 +7,12 @@ Parameters: KubernetesVersion: Description: The Kubernetes version to install Type: String - Default: 1.14 + Default: 1.16 AllowedValues: - - 1.12 - - 1.13 - 1.14 + - 1.15 + - 1.16 + - 1.17 KeyName: Description: The EC2 Key Pair to allow SSH access to the node instances diff --git a/yarn.lock b/yarn.lock index 4c1ae0b398a..85a2ab6257a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -848,10 +848,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.161.0.tgz#661e8d19862dfba0e4c558e2eb6d64b402c1453e" integrity sha512-qsbboEICn08ZoEoAX/TuYygsFaXlzsCY+CfmdOzqvJbOdfHhVXmrJBxd2hP2qqjTZm2PkbRRmn+03+ce1jvatQ== -"@gitlab/ui@20.8.0": - version "20.8.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-20.8.0.tgz#00ecc698d2ed003f87fd59ea408f999c804d7dda" - integrity sha512-BHXDFtY1Zw6rIkUopAJQMoDpWlAKyDZiHR13U6Tv+Ot0G6p6tW6PVVHb12m+fycNxBDKdYisdLJwH8MKNjWRrA== +"@gitlab/ui@20.9.1": + version "20.9.1" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-20.9.1.tgz#1e5960102a0c41925a531d6b8d00072b4717da54" + integrity sha512-6C03BakNqharsDVChnIYzMbp0vj4syfoo4YDxJ1Ktfev8wq4gy5HrcoZt5dgNNXEfmwbyFnJlXaKBNDlLpbmVA== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" |