diff options
64 files changed, 2633 insertions, 917 deletions
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 65d05887453..3d5c03440ea 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, one-var, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */ +/* eslint-disable one-var, consistent-return */ import $ from 'jquery'; import _ from 'underscore'; @@ -32,121 +32,124 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; -function GitLabDropdownInput(input, options) { - const _this = this; - 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); +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(); }); -} -GitLabDropdownInput.prototype.onInput = function(cb) { - this.cb = cb; -}; + 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); + }); + } -function GitLabDropdownFilter(input, options) { - let ref, timeout; - this.input = input; - this.options = options; - 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); - 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)); - } else { - return this.filter(this.input.val()); - } - }); + onInput(cb) { + this.cb = cb; + } } -GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { - return BLUR_KEYCODES.indexOf(keyCode) !== -1; -}; +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()); + }); + } -GitLabDropdownFilter.prototype.filter = function(search_text) { - let elements, group, key, results, tmp; - if (this.options.onFilter) { - this.options.onFilter(search_text); - } - const data = this.options.data(); - if (data != null && !this.options.filterByText) { - results = data; - if (search_text !== '') { - // When data is an array of objects therefore [object Array] e.g. - // [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ] - if (_.isArray(data)) { - results = fuzzaldrinPlus.filter(data, search_text, { - key: this.options.keys, - }); - } else { + 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 (_.isArray(data)) { + results = fuzzaldrinPlus.filter(data, searchText, { + key: this.options.keys, + }); + } // If data is grouped therefore an [object Object]. e.g. // { // groupName1: [ @@ -158,33 +161,32 @@ GitLabDropdownFilter.prototype.filter = function(search_text) { // { prop: 'def' } // ] // } - if (isObject(data)) { + else if (isObject(data)) { results = {}; - for (key in data) { + Object.keys(data).forEach(key => { group = data[key]; - tmp = fuzzaldrinPlus.filter(group, search_text, { + tmp = fuzzaldrinPlus.filter(group, searchText, { key: this.options.keys, }); if (tmp.length) { results[key] = tmp.map(item => item); } - } + }); } } + return this.options.callback(results); } - return this.options.callback(results); - } else { - elements = this.options.elements(); - if (search_text) { + 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(), search_text); + const matches = fuzzaldrinPlus.match($el.text().trim(), searchText); if (!$el.is('.dropdown-header')) { if (matches.length) { return $el.show().removeClass('option-hidden'); - } else { - return $el.hide().addClass('option-hidden'); } + return $el.hide().addClass('option-hidden'); } }); } else { @@ -196,235 +198,240 @@ GitLabDropdownFilter.prototype.filter = function(search_text) { .find('.dropdown-menu-empty-item') .toggleClass('hidden', elements.is(':visible')); } -}; - -function GitLabDropdownRemote(dataEndpoint, options) { - this.dataEndpoint = dataEndpoint; - this.options = options; } -GitLabDropdownRemote.prototype.execute = function() { - if (typeof this.dataEndpoint === 'string') { - return this.fetchData(); - } else if (typeof this.dataEndpoint === 'function') { +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(); } - return this.dataEndpoint('', data => { - // Fetch the data by calling the data function + + // Fetch the data through ajax if the data is a string + return axios.get(this.dataEndpoint).then(({ data }) => { if (this.options.success) { - this.options.success(data); - } - if (this.options.beforeSend) { - return this.options.beforeSend(); + return this.options.success(data); } }); } -}; - -GitLabDropdownRemote.prototype.fetchData = function() { - 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 (_.isString(this.filterInput)) { + this.filterInput = this.getElement(this.filterInput); } - }); -}; - -function GitLabDropdown(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 (_.isString(this.filterInput)) { - 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 (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { - 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, - }); + 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 (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + 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.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}`; } - if ($(this.el).is('input')) { - currentIndex = -1; - } else { - $(selector, this.dropdown) - .first() - .find('a') - .addClass('is-focused'); - currentIndex = 0; + 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'); + // 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, - }); + 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); - } + // Update label right after all modifications in dropdown has been done + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); + } - $el.trigger('blur'); - }); + $el.trigger('blur'); + }); + } } -} -// Finds an element inside wrapper element -GitLabDropdown.prototype.getElement = function(selector) { - return this.dropdown.find(selector); -}; + // Finds an element inside wrapper element + getElement(selector) { + return this.dropdown.find(selector); + } -GitLabDropdown.prototype.toggleLoading = function() { - return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); -}; + toggleLoading() { + return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); + } -GitLabDropdown.prototype.togglePage = function() { - const menu = $('.dropdown-menu', this.dropdown); - if (menu.hasClass(PAGE_TWO_CLASS)) { - if (this.remote) { - this.remote.execute(); + 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(); } - menu.toggleClass(PAGE_TWO_CLASS); - // Focus first visible input on active page - return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); -}; -GitLabDropdown.prototype.parseData = function(data) { - let groupData, html, name; - this.renderedData = data; - if (this.options.filterable && data.length === 0) { - // render no matching results - html = [this.noResults()]; - } else { + 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 - if (isObject(data)) { + else if (isObject(data)) { html = []; - for (name in data) { + + Object.keys(data).forEach(name => { groupData = data[name]; html.push( this.renderItem( @@ -436,461 +443,455 @@ GitLabDropdown.prototype.parseData = function(data) { ), ); this.renderData(groupData, name).map(item => html.push(item)); - } + }); } else { // Render each row html = this.renderData(data); } - } - // Render the full menu - const full_html = this.renderMenu(html); - return this.appendMenu(full_html); -}; - -GitLabDropdown.prototype.renderData = function(data, group) { - return data.map((obj, index) => this.renderItem(obj, group || false, index)); -}; - -GitLabDropdown.prototype.shouldPropagate = function(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; + // 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; + return true; + } } -}; - -GitLabDropdown.prototype.filteredFullData = function() { - return this.fullData.filter( - r => - typeof r === 'object' && - !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && - !Object.prototype.hasOwnProperty.call(r, 'header'), - ); -}; -GitLabDropdown.prototype.opened = function(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), + filteredFullData() { + return this.fullData.filter( + r => + typeof r === 'object' && + !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && + !Object.prototype.hasOwnProperty.call(r, 'header'), ); } - const contentHtml = $('.dropdown-content', this.dropdown).html(); - if (this.remote && contentHtml === '') { - this.remote.execute(); - } else { - this.focusTextInput(); - } + opened(e) { + this.resetRows(); + this.addArrowKeyEvent(); - if (this.options.showMenuAbove) { - this.positionMenuAbove(); - } + 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'); - if (this.options.opened) { - if (this.options.preserveContext) { - this.options.opened(e); - } else { - this.options.opened.call(this, e); + // Makes indeterminate items effective + if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { + this.parseData(this.fullData); } - } - return this.dropdown.trigger('shown.gl.dropdown'); -}; + // 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), + ); + } -GitLabDropdown.prototype.positionMenuAbove = function() { - const $menu = this.dropdown.find('.dropdown-menu'); + const contentHtml = $('.dropdown-content', this.dropdown).html(); + if (this.remote && contentHtml === '') { + this.remote.execute(); + } else { + this.focusTextInput(); + } - $menu.addClass('dropdown-open-top'); - $menu.css('top', 'initial'); - $menu.css('bottom', '100%'); -}; + 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); + } + } -GitLabDropdown.prototype.hidden = function(e) { - this.resetRows(); - this.removeArrowKeyEvent(); - const $input = this.dropdown.find('.dropdown-input-field'); - if (this.options.filterable) { - $input.blur(); + return this.dropdown.trigger('shown.gl.dropdown'); } - if (this.dropdown.find('.dropdown-toggle-page').length) { - $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + + positionMenuAbove() { + const $menu = this.dropdown.find('.dropdown-menu'); + + $menu.addClass('dropdown-open-top'); + $menu.css('top', 'initial'); + $menu.css('bottom', '100%'); } - if (this.options.hidden) { - this.options.hidden.call(this, e); + + 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'); } - return this.dropdown.trigger('hidden.gl.dropdown'); -}; -// Render the full menu -GitLabDropdown.prototype.renderMenu = function(html) { - if (this.options.renderMenu) { - return this.options.renderMenu(html); - } else { + // 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 -GitLabDropdown.prototype.appendMenu = function(html) { - return this.clearMenu().append(html); -}; + // Append the menu into the dropdown + appendMenu(html) { + return this.clearMenu().append(html); + } -GitLabDropdown.prototype.clearMenu = function() { - let selector; - 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'; + 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(); } - return $(selector, this.dropdown).empty(); -}; + renderItem(data, group, index) { + let parent; -GitLabDropdown.prototype.renderItem = function(data, group, index) { - let parent; - - if (this.dropdown && this.dropdown[0]) { - parent = this.dropdown[0].parentNode; - } - - return renderItem({ - instance: this, - options: Object.assign({}, 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, - }); -}; + if (this.dropdown && this.dropdown[0]) { + parent = this.dropdown[0].parentNode; + } -GitLabDropdown.prototype.highlightTemplate = function(text, template) { - return `"<b>${_.escape(text)}</b>" ${template}`; -}; + return renderItem({ + instance: this, + options: Object.assign({}, 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, + }); + } -GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - const occurrences = fuzzaldrinPlus.match(text, term); - const { indexOf } = []; + // eslint-disable-next-line class-methods-use-this + highlightTemplate(text, template) { + return `"<b>${_.escape(text)}</b>" ${template}`; + } - return text - .split('') - .map((character, i) => { - if (indexOf.call(occurrences, i) !== -1) { - return `<b>${character}</b>`; - } else { + // 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]; } - }) - .join(''); -}; + } -GitLabDropdown.prototype.noResults = function() { - return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; -}; + if (this.options.vue) { + if (el.hasClass(ACTIVE_CLASS)) { + el.removeClass(ACTIVE_CLASS); + } else { + el.addClass(ACTIVE_CLASS); + } -GitLabDropdown.prototype.rowClicked = function(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]; + return [selectedObject]; } - } - if (this.options.vue) { - if (el.hasClass(ACTIVE_CLASS)) { - el.removeClass(ACTIVE_CLASS); - } else { - el.addClass(ACTIVE_CLASS); + 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, "\\'")}']`); } - return [selectedObject]; - } + if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { + 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 (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 && 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'); + } + } 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]; } - return [selectedObject, isMarking]; -}; + focusTextInput() { + if (this.options.filterable) { + const initialScrollTop = $(window).scrollTop(); -GitLabDropdown.prototype.focusTextInput = function() { - if (this.options.filterable) { - const initialScrollTop = $(window).scrollTop(); + if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { + this.filterInput.focus(); + } - if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { - this.filterInput.focus(); + if ($(window).scrollTop() < initialScrollTop) { + $(window).scrollTop(initialScrollTop); + } } + } - if ($(window).scrollTop() < initialScrollTop) { - $(window).scrollTop(initialScrollTop); + addInput(fieldName, value, selectedObject, single) { + // Create hidden input for form + if (single) { + $(`input[name="${fieldName}"]`).remove(); } - } -}; -GitLabDropdown.prototype.addInput = function(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); + } - 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.multiSelect) { - Object.keys(selectedObject).forEach(attribute => { - $input.attr(`data-${attribute}`, selectedObject[attribute]); - }); - } + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } - if (this.options.inputMeta) { - $input.attr('data-meta', selectedObject[this.options.inputMeta]); + this.dropdown.before($input).trigger('change'); } - this.dropdown.before($input).trigger('change'); -}; - -GitLabDropdown.prototype.selectRowAtIndex = function(index) { - let selector; - // If we pass an option index - 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); + selectRowAtIndex(index) { + // If we pass an option index + let selector; + if (typeof index !== 'undefined') { + selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; } else { - $el.trigger('click'); + 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'); + } } } -}; -GitLabDropdown.prototype.addArrowKeyEvent = function() { - let selector; - const ARROW_KEY_CODES = [38, 40]; - 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; + 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; + } } - } else if (currentKeyCode === 38) { - // Move up - if (currentIndex > 0) { - currentIndex -= 1; + if (currentIndex !== PREV_INDEX) { + this.highlightRowAtIndex($listItems, currentIndex); } + return false; } - if (currentIndex !== PREV_INDEX) { - this.highlightRowAtIndex($listItems, currentIndex); + if (currentKeyCode === 13 && currentIndex !== -1) { + e.preventDefault(); + this.selectRowAtIndex(); } - return false; - } - if (currentKeyCode === 13 && currentIndex !== -1) { - e.preventDefault(); - this.selectRowAtIndex(); - } - }); -}; - -GitLabDropdown.prototype.removeArrowKeyEvent = function() { - return $('body').off('keydown'); -}; - -GitLabDropdown.prototype.resetRows = function resetRows() { - currentIndex = -1; - $('.is-focused', this.dropdown).removeClass('is-focused'); -}; - -GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { - if (!$listItems) { - $listItems = $(SELECTABLE_CLASSES, this.dropdown); - } - - // Remove the class for the previously focused row - $('.is-focused', this.dropdown).removeClass('is-focused'); - // Update the class for the row at the specific index - 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, - ); + }); } -}; -GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { - if (selected == null) { - selected = null; + // eslint-disable-next-line class-methods-use-this + removeArrowKeyEvent() { + return $('body').off('keydown'); } - if (el == null) { - el = null; - } - if (instance == null) { - instance = null; + + resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); } - let toggleText = this.options.toggleLabel(selected, el, instance); - if (this.options.updateLabel) { - // Option to override the dropdown label text - toggleText = this.options.updateLabel; + 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, + ); + } } - return $(this.el) - .find('.dropdown-toggle-text') - .text(toggleText); -}; + 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; + } -GitLabDropdown.prototype.clearField = function(field, isInput) { - return isInput ? field.val('') : field.remove(); -}; + 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(); + } +} +// eslint-disable-next-line func-names $.fn.glDropdown = function(opts) { + // eslint-disable-next-line func-names return this.each(function() { if (!$.data(this, 'glDropdown')) { return $.data(this, 'glDropdown', new GitLabDropdown(this, opts)); diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 334fde23b74..5456a36aefc 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -10,6 +10,7 @@ import { GlLoadingIcon, } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import { NAME_REGEX_LENGTH, UPDATE_SETTINGS_ERROR_MESSAGE, @@ -27,10 +28,18 @@ export default { GlCard, GlLoadingIcon, }, + mixins: [Tracking.mixin()], labelsConfig: { cols: 3, align: 'right', }, + data() { + return { + tracking: { + label: 'docker_container_retention_and_expiration_policies', + }, + }; + }, computed: { ...mapState(['formOptions', 'isLoading']), ...mapComputed( @@ -86,7 +95,12 @@ export default { }, methods: { ...mapActions(['resetSettings', 'saveSettings']), + reset() { + this.track('reset_form'); + this.resetSettings(); + }, submit() { + this.track('submit_form'); this.saveSettings() .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' })); @@ -96,7 +110,7 @@ export default { </script> <template> - <form ref="form-element" @submit.prevent="submit" @reset.prevent="resetSettings"> + <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> <gl-card> <template #header> {{ s__('ContainerRegistry|Tag expiration policy') }} diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 20daf6d71b3..f4e79ae6060 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -3,7 +3,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController include InternalRedirect + # NOTE: Use @application_setting in this controller when you need to access + # application_settings after it has been modified. This is because the + # ApplicationSetting model uses Gitlab::ThreadMemoryCache for caching and the + # cache might be stale immediately after an update. + # https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233 before_action :set_application_setting + before_action :whitelist_query_limiting, only: [:usage_data] before_action :validate_self_monitoring_feature_flag_enabled, only: [ :create_self_monitoring_project, @@ -79,6 +85,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url end + # Specs are in spec/requests/self_monitoring_project_spec.rb def create_self_monitoring_project job_id = SelfMonitoringProjectCreateWorker.perform_async @@ -88,6 +95,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + # Specs are in spec/requests/self_monitoring_project_spec.rb def status_create_self_monitoring_project job_id = params[:job_id].to_s @@ -98,10 +106,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end - if Gitlab::CurrentSettings.self_monitoring_project_id.present? - return render status: :ok, json: self_monitoring_data - - elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id) + if SelfMonitoringProjectCreateWorker.in_progress?(job_id) ::Gitlab::PollingInterval.set_header(response, interval: 3_000) return render status: :accepted, json: { @@ -109,12 +114,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + if @application_setting.self_monitoring_project_id.present? + return render status: :ok, json: self_monitoring_data + end + render status: :bad_request, json: { message: _('Self-monitoring project does not exist. Please check logs ' \ 'for any error messages') } end + # Specs are in spec/requests/self_monitoring_project_spec.rb def delete_self_monitoring_project job_id = SelfMonitoringProjectDeleteWorker.perform_async @@ -124,6 +134,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + # Specs are in spec/requests/self_monitoring_project_spec.rb def status_delete_self_monitoring_project job_id = params[:job_id].to_s @@ -134,12 +145,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end - if Gitlab::CurrentSettings.self_monitoring_project_id.nil? - return render status: :ok, json: { - message: _('Self-monitoring project has been successfully deleted') - } - - elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id) + if SelfMonitoringProjectDeleteWorker.in_progress?(job_id) ::Gitlab::PollingInterval.set_header(response, interval: 3_000) return render status: :accepted, json: { @@ -147,6 +153,12 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + if @application_setting.self_monitoring_project_id.nil? + return render status: :ok, json: { + message: _('Self-monitoring project has been successfully deleted') + } + end + render status: :bad_request, json: { message: _('Self-monitoring project was not deleted. Please check logs ' \ 'for any error messages') @@ -161,8 +173,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def self_monitoring_data { - project_id: Gitlab::CurrentSettings.self_monitoring_project_id, - project_full_path: Gitlab::CurrentSettings.self_monitoring_project&.full_path + project_id: @application_setting.self_monitoring_project_id, + project_full_path: @application_setting.self_monitoring_project&.full_path } end diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index a41d8a22650..a317f4086c6 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -24,7 +24,7 @@ class Admin::SpamLogsController < Admin::ApplicationController def mark_as_ham spam_log = SpamLog.find(params[:id]) - if HamService.new(spam_log).mark_as_ham! + if Spam::HamService.new(spam_log).mark_as_ham! redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.') else redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.') diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb index 63455ff3acb..72c5c19c25c 100644 --- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb @@ -8,7 +8,6 @@ module Resolvers description: 'ID of the Sentry issue' def resolve(**args) - project = object current_user = context[:current_user] issue_id = GlobalID.parse(args[:id]).model_id @@ -23,6 +22,14 @@ module Resolvers issue end + + private + + def project + return object.gitlab_project if object.respond_to?(:gitlab_project) + + object + end end end end diff --git a/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb new file mode 100644 index 00000000000..e4b4854c273 --- /dev/null +++ b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module ErrorTracking + class SentryErrorCollectionResolver < BaseResolver + def resolve(**args) + project = object + + service = ::ErrorTracking::ListIssuesService.new( + project, + context[:current_user] + ) + + Gitlab::ErrorTracking::ErrorCollection.new( + external_url: service.external_url, + project: project + ) + end + end + end +end diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb new file mode 100644 index 00000000000..79f99709505 --- /dev/null +++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module ErrorTracking + class SentryErrorsResolver < BaseResolver + def resolve(**args) + args[:cursor] = args.delete(:after) + project = object.project + + result = ::ErrorTracking::ListIssuesService.new( + project, + context[:current_user], + args + ).execute + + next_cursor = result[:pagination]&.dig('next', 'cursor') + previous_cursor = result[:pagination]&.dig('previous', 'cursor') + issues = result[:issues] + + # ReactiveCache is still fetching data + return if issues.nil? + + Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues) + end + end + end +end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index e3ccf9e61c8..124398f28e7 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -4,8 +4,9 @@ module Types module ErrorTracking class SentryDetailedErrorType < ::Types::BaseObject graphql_name 'SentryDetailedError' + description 'A Sentry error.' - present_using SentryDetailedErrorPresenter + present_using SentryErrorPresenter authorize :read_sentry_issue @@ -92,18 +93,6 @@ module Types field :tags, Types::ErrorTracking::SentryErrorTagsType, null: false, description: 'Tags associated with the Sentry Error' - - def first_seen - DateTime.parse(object.first_seen) - end - - def last_seen - DateTime.parse(object.last_seen) - end - - def project_id - Gitlab::GlobalId.build(model_name: 'Project', id: object.project_id).to_s - end end end end diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb new file mode 100644 index 00000000000..2e1b75ac84c --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + class SentryErrorCollectionType < ::Types::BaseObject + graphql_name 'SentryErrorCollection' + description 'An object containing a collection of Sentry errors, and a detailed error.' + + authorize :read_sentry_issue + + field :errors, + Types::ErrorTracking::SentryErrorType.connection_type, + connection: false, + null: true, + description: "Collection of Sentry Errors", + extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], + resolver: Resolvers::ErrorTracking::SentryErrorsResolver do + argument :search_term, + String, + description: 'Search term for the Sentry error.', + required: false + argument :sort, + String, + description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.', + required: false + end + field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType, + null: true, + description: 'Detailed version of a Sentry error on the project', + resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + field :external_url, + GraphQL::STRING_TYPE, + null: true, + description: "External URL for Sentry" + end + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_type.rb b/app/graphql/types/error_tracking/sentry_error_type.rb new file mode 100644 index 00000000000..7a842025e45 --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_type.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + # rubocop: disable Graphql/AuthorizeTypes + class SentryErrorType < ::Types::BaseObject + graphql_name 'SentryError' + description 'A Sentry error. A simplified version of SentryDetailedError.' + + present_using SentryErrorPresenter + + field :id, GraphQL::ID_TYPE, + null: false, + description: 'ID (global ID) of the error' + field :sentry_id, GraphQL::STRING_TYPE, + method: :id, + null: false, + description: 'ID (Sentry ID) of the error' + field :first_seen, Types::TimeType, + null: false, + description: 'Timestamp when the error was first seen' + field :last_seen, Types::TimeType, + null: false, + description: 'Timestamp when the error was last seen' + field :title, GraphQL::STRING_TYPE, + null: false, + description: 'Title of the error' + field :type, GraphQL::STRING_TYPE, + null: false, + description: 'Type of the error' + field :user_count, GraphQL::INT_TYPE, + null: false, + description: 'Count of users affected by the error' + field :count, GraphQL::INT_TYPE, + null: false, + description: 'Count of occurrences' + field :message, GraphQL::STRING_TYPE, + null: true, + description: 'Sentry metadata message of the error' + field :culprit, GraphQL::STRING_TYPE, + null: false, + description: 'Culprit of the error' + field :external_url, GraphQL::STRING_TYPE, + null: false, + description: 'External URL of the error' + field :short_id, GraphQL::STRING_TYPE, + null: false, + description: 'Short ID (Sentry ID) of the error' + field :status, Types::ErrorTracking::SentryErrorStatusEnum, + null: false, + description: 'Status of the error' + field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], + null: false, + description: 'Last 24hr stats of the error' + field :sentry_project_id, GraphQL::ID_TYPE, + method: :project_id, + null: false, + description: 'ID of the project (Sentry project)' + field :sentry_project_name, GraphQL::STRING_TYPE, + method: :project_name, + null: false, + description: 'Name of the project affected by the error' + field :sentry_project_slug, GraphQL::STRING_TYPE, + method: :project_slug, + null: false, + description: 'Slug of the project affected by the error' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5ece4926951..b44baa50955 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -173,6 +173,12 @@ module Types null: true, description: 'Snippets of the project', resolver: Resolvers::Projects::SnippetsResolver + + field :sentry_errors, + Types::ErrorTracking::SentryErrorCollectionType, + null: true, + description: 'Paginated collection of Sentry errors on the project', + resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver end end diff --git a/app/models/commit.rb b/app/models/commit.rb index 460725b2016..31a890096e9 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -484,10 +484,10 @@ class Commit end def commit_reference(from, referable_commit_id, full: false) - reference = project.to_reference(from, full: full) + base = project.to_reference_base(from, full: full) - if reference.present? - "#{reference}#{self.class.reference_prefix}#{referable_commit_id}" + if base.present? + "#{base}#{self.class.reference_prefix}#{referable_commit_id}" else referable_commit_id end diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 08ca86bc902..08f1eb3731e 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -92,7 +92,7 @@ class CommitRange alias_method :id, :to_s def to_reference(from = nil, full: false) - project_reference = project.to_reference(from, full: full) + project_reference = project.to_reference_base(from, full: full) if project_reference.present? project_reference + self.class.reference_prefix + self.id @@ -102,7 +102,7 @@ class CommitRange end def reference_link_text(from = nil) - project_reference = project.to_reference(from) + project_reference = project.to_reference_base(from) reference = ref_from + notation + ref_to if project_reference.present? diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 3b0606aa425..40edd3b3ead 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -23,6 +23,14 @@ module Referable '' end + # If this referable object can serve as the base for the + # reference of child objects (e.g. projects are the base of + # issues), but it is formatted differently, then you may wish + # to override this method. + def to_reference_base(from = nil, full:) + to_reference(from, full: full) + end + def reference_link_text(from = nil) to_reference(from) end diff --git a/app/models/issue.rb b/app/models/issue.rb index bf600278162..3823b5e0fba 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -173,7 +173,7 @@ class Issue < ApplicationRecord def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" end def suggested_branch_name diff --git a/app/models/label.rb b/app/models/label.rb index dbb96a2b9da..938ecb323e2 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -225,7 +225,7 @@ class Label < ApplicationRecord reference = "#{self.class.reference_prefix}#{format_reference}" if from - "#{from.to_reference(target_project, full: full)}#{reference}" + "#{from.to_reference_base(target_project, full: full)}#{reference}" else reference end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 50346d97d8b..48c5c0152b5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -396,7 +396,7 @@ class MergeRequest < ApplicationRecord def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" end def commits(limit: nil) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 5da92fc4bc5..f709e518047 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -228,7 +228,7 @@ class Milestone < ApplicationRecord reference = "#{self.class.reference_prefix}#{format_reference}" if project - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" else reference end diff --git a/app/models/project.rb b/app/models/project.rb index f8c201d73e5..3aa8430f3a2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1068,12 +1068,19 @@ class Project < ApplicationRecord end end - def to_reference_with_postfix - "#{to_reference(full: true)}#{self.class.reference_postfix}" + # Produce a valid reference (see Referable#to_reference) + # + # NB: For projects, all references are 'full' - i.e. they all include the + # full_path, rather than just the project name. For this reason, we ignore + # the value of `full:` passed to this method, which is part of the Referable + # interface. + def to_reference(from = nil, full: false) + base = to_reference_base(from, full: true) + "#{base}#{self.class.reference_postfix}" end # `from` argument can be a Namespace or Project. - def to_reference(from = nil, full: false) + def to_reference_base(from = nil, full: false) if full || cross_namespace_reference?(from) full_path elsif cross_project_reference?(from) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 19685cdb78e..77ec683f584 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -180,7 +180,7 @@ class Snippet < ApplicationRecord reference = "#{self.class.reference_prefix}#{id}" if project.present? - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" else reference end diff --git a/app/policies/error_tracking/detailed_error_policy.rb b/app/policies/error_tracking/base_policy.rb index cb74242d46a..ea56106ed89 100644 --- a/app/policies/error_tracking/detailed_error_policy.rb +++ b/app/policies/error_tracking/base_policy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ErrorTracking - class DetailedErrorPolicy < BasePolicy + class BasePolicy < ::BasePolicy delegate { @subject.gitlab_project } end end diff --git a/app/presenters/sentry_detailed_error_presenter.rb b/app/presenters/sentry_error_presenter.rb index 9329f987879..ba724b0f8be 100644 --- a/app/presenters/sentry_detailed_error_presenter.rb +++ b/app/presenters/sentry_error_presenter.rb @@ -1,10 +1,22 @@ # frozen_string_literal: true -class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated +class SentryErrorPresenter < Gitlab::View::Presenter::Delegated presents :error FrequencyStruct = Struct.new(:time, :count, keyword_init: true) + def first_seen + DateTime.parse(error.first_seen) + end + + def last_seen + DateTime.parse(error.last_seen) + end + + def project_id + Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s + end + def frequency utc_offset = Time.zone_offset('UTC') diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb deleted file mode 100644 index 0bbdaa47a1b..00000000000 --- a/app/services/ham_service.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class HamService - attr_accessor :spam_log - - def initialize(spam_log) - @spam_log = spam_log - end - - def mark_as_ham! - if akismet.submit_ham - spam_log.update_attribute(:submitted_as_ham, true) - else - false - end - end - - private - - def akismet - user = spam_log.user - @akismet ||= AkismetService.new( - user.name, - user.email, - spam_log.text, - ip_address: spam_log.source_ip, - user_agent: spam_log.user_agent - ) - end -end diff --git a/app/services/spam/ham_service.rb b/app/services/spam/ham_service.rb new file mode 100644 index 00000000000..f367eb8c21e --- /dev/null +++ b/app/services/spam/ham_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Spam + class HamService + attr_accessor :spam_log + + def initialize(spam_log) + @spam_log = spam_log + end + + def mark_as_ham! + if akismet.submit_ham + spam_log.update_attribute(:submitted_as_ham, true) + else + false + end + end + + private + + def akismet + user = spam_log.user + @akismet ||= AkismetService.new( + user.name, + user.email, + spam_log.text, + ip_address: spam_log.source_ip, + user_agent: spam_log.user_agent + ) + end + end +end diff --git a/changelogs/unreleased/35897-grapghql-error-tracking-list-errors.yml b/changelogs/unreleased/35897-grapghql-error-tracking-list-errors.yml new file mode 100644 index 00000000000..f27ffed98e3 --- /dev/null +++ b/changelogs/unreleased/35897-grapghql-error-tracking-list-errors.yml @@ -0,0 +1,5 @@ +--- +title: Add querying of Sentry errors to Graphql +merge_request: 21802 +author: +type: added diff --git a/changelogs/unreleased/Refactor-gl_dropdown-js.yml b/changelogs/unreleased/Refactor-gl_dropdown-js.yml new file mode 100644 index 00000000000..4e61de6b3da --- /dev/null +++ b/changelogs/unreleased/Refactor-gl_dropdown-js.yml @@ -0,0 +1,5 @@ +--- +title: refactoring gl_dropdown.js to use ES6 classes instead of constructor functions +merge_request: 20488 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/jswain_add_renewal_link_to_expiration_banner.yml b/changelogs/unreleased/jswain_add_renewal_link_to_expiration_banner.yml new file mode 100644 index 00000000000..8b4507dc8f3 --- /dev/null +++ b/changelogs/unreleased/jswain_add_renewal_link_to_expiration_banner.yml @@ -0,0 +1,5 @@ +--- +title: Add license FAQ link to license expired message +merge_request: +author: +type: added diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index 434cb2447c8..1521c48f6fd 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -342,16 +342,28 @@ pages: 1. [Reconfigure GitLab][reconfigure] for the changes to take effect. -### Using a custom Certificate Authority (CA) with Access Control +### Using a custom Certificate Authority (CA) -When using certificates issued by a custom CA, Access Control on GitLab Pages may fail to work if the custom CA is not recognized. +When using certificates issued by a custom CA, [Access Control](../../user/project/pages/pages_access_control.md#gitlab-pages-access-control) and +the [online view of HTML job artifacts](../../user/project/pipelines/job_artifacts.md#browsing-artifacts) +will fail to work if the custom CA is not recognized. This usually results in this error: `Post /oauth/token: x509: certificate signed by unknown authority`. -For GitLab Pages Access Control with TLS/SSL certs issued by an internal or custom CA: +For installation from source this can be fixed by installing the custom Certificate +Authority (CA) in the system certificate store. -1. Copy the certificate bundle to `/opt/gitlab/embedded/ssl/certs/` in `.pem` format. +For Omnibus, normally this would be fixed by [installing a custom CA in GitLab Omnibus](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates) +but a [bug](https://gitlab.com/gitlab-org/gitlab/issues/25411) is currently preventing +that method from working. Use the following workaround: + +1. Append your GitLab server TLS/SSL certficate to `/opt/gitlab/embedded/ssl/certs/cacert.pem` where `gitlab-domain-example.com` is your GitLab application URL + + ```bash + printf "\ngitlab-domain-example.com\n===========================\n" | sudo tee --append /opt/gitlab/embedded/ssl/certs/cacert.pem + echo -n | openssl s_client -connect gitlab-domain-example.com:443 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | sudo tee --append /opt/gitlab/embedded/ssl/certs/cacert.pem + ``` 1. [Restart](../restart_gitlab.md) the GitLab Pages Daemon. For GitLab Omnibus instances: @@ -359,6 +371,9 @@ For GitLab Pages Access Control with TLS/SSL certs issued by an internal or cust sudo gitlab-ctl restart gitlab-pages ``` +CAUTION: **Caution:** +Some GitLab Omnibus upgrades will revert this workaround and you'll need to apply it again. + ## Activate verbose logging for daemon Verbose logging was [introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/2533) in diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 39b34e72e24..3cb0690abc8 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -5454,6 +5454,11 @@ type Project { ): SentryDetailedError """ + Paginated collection of Sentry errors on the project + """ + sentryErrors: SentryErrorCollection + + """ E-mail address of the service desk. """ serviceDeskAddress: String @@ -6054,6 +6059,9 @@ type RootStorageStatistics { wikiSize: Int! } +""" +A Sentry error. +""" type SentryDetailedError { """ Count of occurrences @@ -6186,6 +6194,186 @@ type SentryDetailedError { userCount: Int! } +""" +A Sentry error. A simplified version of SentryDetailedError. +""" +type SentryError { + """ + Count of occurrences + """ + count: Int! + + """ + Culprit of the error + """ + culprit: String! + + """ + External URL of the error + """ + externalUrl: String! + + """ + Timestamp when the error was first seen + """ + firstSeen: Time! + + """ + Last 24hr stats of the error + """ + frequency: [SentryErrorFrequency!]! + + """ + ID (global ID) of the error + """ + id: ID! + + """ + Timestamp when the error was last seen + """ + lastSeen: Time! + + """ + Sentry metadata message of the error + """ + message: String + + """ + ID (Sentry ID) of the error + """ + sentryId: String! + + """ + ID of the project (Sentry project) + """ + sentryProjectId: ID! + + """ + Name of the project affected by the error + """ + sentryProjectName: String! + + """ + Slug of the project affected by the error + """ + sentryProjectSlug: String! + + """ + Short ID (Sentry ID) of the error + """ + shortId: String! + + """ + Status of the error + """ + status: SentryErrorStatus! + + """ + Title of the error + """ + title: String! + + """ + Type of the error + """ + type: String! + + """ + Count of users affected by the error + """ + userCount: Int! +} + +""" +An object containing a collection of Sentry errors, and a detailed error. +""" +type SentryErrorCollection { + """ + Detailed version of a Sentry error on the project + """ + detailedError( + """ + ID of the Sentry issue + """ + id: ID! + ): SentryDetailedError + + """ + Collection of Sentry Errors + """ + errors( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Search term for the Sentry error. + """ + searchTerm: String + + """ + Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default. + """ + sort: String + ): SentryErrorConnection + + """ + External URL for Sentry + """ + externalUrl: String +} + +""" +The connection type for SentryError. +""" +type SentryErrorConnection { + """ + A list of edges. + """ + edges: [SentryErrorEdge] + + """ + A list of nodes. + """ + nodes: [SentryError] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type SentryErrorEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: SentryError +} + type SentryErrorFrequency { """ Count of errors received since the previously recorded time diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 2d2bcaf32bd..8e94d4b33d3 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -1434,6 +1434,20 @@ "deprecationReason": null }, { + "name": "sentryErrors", + "description": "Paginated collection of Sentry errors on the project", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "SentryErrorCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "serviceDeskAddress", "description": "E-mail address of the service desk.", "args": [ @@ -16708,7 +16722,7 @@ { "kind": "OBJECT", "name": "SentryDetailedError", - "description": null, + "description": "A Sentry error.", "fields": [ { "name": "count", @@ -17410,6 +17424,568 @@ }, { "kind": "OBJECT", + "name": "SentryErrorCollection", + "description": "An object containing a collection of Sentry errors, and a detailed error.", + "fields": [ + { + "name": "detailedError", + "description": "Detailed version of a Sentry error on the project", + "args": [ + { + "name": "id", + "description": "ID of the Sentry issue", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SentryDetailedError", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Collection of Sentry Errors", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "searchTerm", + "description": "Search term for the Sentry error.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SentryErrorConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalUrl", + "description": "External URL for Sentry", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SentryErrorConnection", + "description": "The connection type for SentryError.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SentryErrorEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SentryError", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SentryErrorEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "SentryError", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SentryError", + "description": "A Sentry error. A simplified version of SentryDetailedError.", + "fields": [ + { + "name": "count", + "description": "Count of occurrences", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "culprit", + "description": "Culprit of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalUrl", + "description": "External URL of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "firstSeen", + "description": "Timestamp when the error was first seen", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequency", + "description": "Last 24hr stats of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SentryErrorFrequency", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID (global ID) of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastSeen", + "description": "Timestamp when the error was last seen", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "message", + "description": "Sentry metadata message of the error", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sentryId", + "description": "ID (Sentry ID) of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sentryProjectId", + "description": "ID of the project (Sentry project)", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sentryProjectName", + "description": "Name of the project affected by the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sentryProjectSlug", + "description": "Slug of the project affected by the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shortId", + "description": "Short ID (Sentry ID) of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": "Status of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "SentryErrorStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Title of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Type of the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userCount", + "description": "Count of users affected by the error", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "Metadata", "description": null, "fields": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 790e55d437f..dc6517f7ea4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -815,6 +815,7 @@ Information about pagination in a connection. | `repository` | Repository | Git repository of the project | | `requestAccessEnabled` | Boolean | Indicates if users can request member access to the project | | `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project | +| `sentryErrors` | SentryErrorCollection | Paginated collection of Sentry errors on the project | | `serviceDeskAddress` | String | E-mail address of the service desk. | | `serviceDeskEnabled` | Boolean | Indicates if the project has service desk enabled. | | `sharedRunnersEnabled` | Boolean | Indicates if shared runners are enabled on the project | @@ -919,6 +920,8 @@ Autogenerated return type of RemoveAwardEmoji ## SentryDetailedError +A Sentry error. + | Name | Type | Description | | --- | ---- | ---------- | | `count` | Int! | Count of occurrences | @@ -948,6 +951,40 @@ Autogenerated return type of RemoveAwardEmoji | `type` | String! | Type of the error | | `userCount` | Int! | Count of users affected by the error | +## SentryError + +A Sentry error. A simplified version of SentryDetailedError. + +| Name | Type | Description | +| --- | ---- | ---------- | +| `count` | Int! | Count of occurrences | +| `culprit` | String! | Culprit of the error | +| `externalUrl` | String! | External URL of the error | +| `firstSeen` | Time! | Timestamp when the error was first seen | +| `frequency` | SentryErrorFrequency! => Array | Last 24hr stats of the error | +| `id` | ID! | ID (global ID) of the error | +| `lastSeen` | Time! | Timestamp when the error was last seen | +| `message` | String | Sentry metadata message of the error | +| `sentryId` | String! | ID (Sentry ID) of the error | +| `sentryProjectId` | ID! | ID of the project (Sentry project) | +| `sentryProjectName` | String! | Name of the project affected by the error | +| `sentryProjectSlug` | String! | Slug of the project affected by the error | +| `shortId` | String! | Short ID (Sentry ID) of the error | +| `status` | SentryErrorStatus! | Status of the error | +| `title` | String! | Title of the error | +| `type` | String! | Type of the error | +| `userCount` | Int! | Count of users affected by the error | + +## SentryErrorCollection + +An object containing a collection of Sentry errors, and a detailed error. + +| Name | Type | Description | +| --- | ---- | ---------- | +| `detailedError` | SentryDetailedError | Detailed version of a Sentry error on the project | +| `errors` | SentryErrorConnection | Collection of Sentry Errors | +| `externalUrl` | String | External URL for Sentry | + ## SentryErrorFrequency | Name | Type | Description | diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index 78702ce173c..4fc9c35b2d2 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -385,6 +385,21 @@ NOTE: **Note:** The usage of `perform_enqueued_jobs` is currently useless since our workers aren't inheriting from `ApplicationJob` / `ActiveJob::Base`. +#### DNS + +DNS requests are stubbed universally in the test suite +(as of [!22368](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22368)), as DNS can +cause issues depending on the developer's local network. There are RSpec labels +available in `spec/support/dns.rb` which you can apply to tests if you need to +bypass the DNS stubbing, e.g.: + +``` +it "really connects to Prometheus", :permit_dns do +``` + +And if you need more specific control, the DNS blocking is implemented in +`spec/support/helpers/dns_helpers.rb` and these methods can be called elsewhere. + #### Filesystem Filesystem data can be roughly split into "repositories", and "everything else". diff --git a/lib/banzai/filter/milestone_reference_filter.rb b/lib/banzai/filter/milestone_reference_filter.rb index 4c47ee4dba1..126208db935 100644 --- a/lib/banzai/filter/milestone_reference_filter.rb +++ b/lib/banzai/filter/milestone_reference_filter.rb @@ -121,7 +121,7 @@ module Banzai def object_link_text(object, matches) milestone_link = escape_once(super) - reference = object.project&.to_reference(project) + reference = object.project&.to_reference_base(project) if reference.present? "#{milestone_link} <i>in #{reference}</i>".html_safe diff --git a/lib/banzai/filter/project_reference_filter.rb b/lib/banzai/filter/project_reference_filter.rb index 83cf45097ed..292d4b1d56c 100644 --- a/lib/banzai/filter/project_reference_filter.rb +++ b/lib/banzai/filter/project_reference_filter.rb @@ -104,7 +104,7 @@ module Banzai def link_to_project(project, link_content: nil) url = urls.project_url(project, only_path: context[:only_path]) data = data_attribute(project: project.id) - content = link_content || project.to_reference_with_postfix + content = link_content || project.to_reference link_tag(url, data, content, project.name) end diff --git a/lib/gitlab/error_tracking/detailed_error.rb b/lib/gitlab/error_tracking/detailed_error.rb index c240ec1fa4f..b49f2472e01 100644 --- a/lib/gitlab/error_tracking/detailed_error.rb +++ b/lib/gitlab/error_tracking/detailed_error.rb @@ -35,7 +35,7 @@ module Gitlab :user_count def self.declarative_policy_class - 'ErrorTracking::DetailedErrorPolicy' + 'ErrorTracking::BasePolicy' end end end diff --git a/lib/gitlab/error_tracking/error.rb b/lib/gitlab/error_tracking/error.rb index 4af5192aa6a..6bfb9dae610 100644 --- a/lib/gitlab/error_tracking/error.rb +++ b/lib/gitlab/error_tracking/error.rb @@ -4,11 +4,16 @@ module Gitlab module ErrorTracking class Error include ActiveModel::Model + include GlobalID::Identification attr_accessor :id, :title, :type, :user_count, :count, :first_seen, :last_seen, :message, :culprit, :external_url, :project_id, :project_name, :project_slug, :short_id, :status, :frequency + + def self.declarative_policy_class + 'ErrorTracking::BasePolicy' + end end end end diff --git a/lib/gitlab/error_tracking/error_collection.rb b/lib/gitlab/error_tracking/error_collection.rb new file mode 100644 index 00000000000..56bcb671363 --- /dev/null +++ b/lib/gitlab/error_tracking/error_collection.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module ErrorTracking + class ErrorCollection + include GlobalID::Identification + + attr_accessor :issues, :external_url, :project + + alias_attribute :gitlab_project, :project + + def initialize(project:, external_url: nil, issues: []) + @project = project + @external_url = external_url + @issues = issues + end + + def self.declarative_policy_class + 'ErrorTracking::BasePolicy' + end + end + end +end diff --git a/lib/gitlab/graphql/extensions/externally_paginated_array_extension.rb b/lib/gitlab/graphql/extensions/externally_paginated_array_extension.rb new file mode 100644 index 00000000000..1adedb500e6 --- /dev/null +++ b/lib/gitlab/graphql/extensions/externally_paginated_array_extension.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +module Gitlab + module Graphql + module Extensions + class ExternallyPaginatedArrayExtension < GraphQL::Schema::Field::ConnectionExtension + def resolve(object:, arguments:, context:) + yield(object, arguments) + end + end + end + end +end diff --git a/lib/gitlab/import_export/project_tree_loader.rb b/lib/gitlab/import_export/project_tree_loader.rb new file mode 100644 index 00000000000..fc21858043d --- /dev/null +++ b/lib/gitlab/import_export/project_tree_loader.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + class ProjectTreeLoader + def load(path, dedup_entries: false) + tree_hash = ActiveSupport::JSON.decode(IO.read(path)) + + if dedup_entries + dedup_tree(tree_hash) + else + tree_hash + end + end + + private + + # This function removes duplicate entries from the given tree recursively + # by caching nodes it encounters repeatedly. We only consider nodes for + # which there can actually be multiple equivalent instances (e.g. strings, + # hashes and arrays, but not `nil`s, numbers or booleans.) + # + # The algorithm uses a recursive depth-first descent with 3 cases, starting + # with a root node (the tree/hash itself): + # - a node has already been cached; in this case we return it from the cache + # - a node has not been cached yet but should be; descend into its children + # - a node is neither cached nor qualifies for caching; this is a no-op + def dedup_tree(node, nodes_seen = {}) + if nodes_seen.key?(node) && distinguishable?(node) + yield nodes_seen[node] + elsif should_dedup?(node) + nodes_seen[node] = node + + case node + when Array + node.each_index do |idx| + dedup_tree(node[idx], nodes_seen) do |cached_node| + node[idx] = cached_node + end + end + when Hash + node.each do |k, v| + dedup_tree(v, nodes_seen) do |cached_node| + node[k] = cached_node + end + end + end + else + node + end + end + + # We do not need to consider nodes for which there cannot be multiple instances + def should_dedup?(node) + node && !(node.is_a?(Numeric) || node.is_a?(TrueClass) || node.is_a?(FalseClass)) + end + + # We can only safely de-dup values that are distinguishable. True value objects + # are always distinguishable by nature. Hashes however can represent entities, + # which are identified by ID, not value. We therefore disallow de-duping hashes + # that do not have an `id` field, since we might risk dropping entities that + # have equal attributes yet different identities. + def distinguishable?(node) + if node.is_a?(Hash) + node.key?('id') + else + true + end + end + end + end +end diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index c4ac6a3a3f2..aae07657ea0 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -3,15 +3,17 @@ module Gitlab module ImportExport class ProjectTreeRestorer + LARGE_PROJECT_FILE_SIZE_BYTES = 500.megabyte + attr_reader :user attr_reader :shared attr_reader :project def initialize(user:, shared:, project:) - @path = File.join(shared.export_path, 'project.json') @user = user @shared = shared @project = project + @tree_loader = ProjectTreeLoader.new end def restore @@ -36,9 +38,16 @@ module Gitlab private + def large_project?(path) + File.size(path) >= LARGE_PROJECT_FILE_SIZE_BYTES + end + def read_tree_hash - json = IO.read(@path) - ActiveSupport::JSON.decode(json) + path = File.join(@shared.export_path, 'project.json') + dedup_entries = large_project?(path) && + Feature.enabled?(:dedup_project_import_metadata, project.group) + + @tree_loader.load(path, dedup_entries: dedup_entries) rescue => e Rails.logger.error("Import/Export error: #{e.message}") # rubocop:disable Gitlab/RailsLogger raise Gitlab::ImportExport::Error.new('Incorrect JSON format') diff --git a/lib/gitlab/import_export/relation_tree_restorer.rb b/lib/gitlab/import_export/relation_tree_restorer.rb index 3606e1c5bd9..cc01d70db16 100644 --- a/lib/gitlab/import_export/relation_tree_restorer.rb +++ b/lib/gitlab/import_export/relation_tree_restorer.rb @@ -159,7 +159,7 @@ module Gitlab def build_relation(relation_key, relation_definition, data_hash) # TODO: This is hack to not create relation for the author # Rather make `RelationFactory#set_note_author` to take care of that - return data_hash if relation_key == 'author' + return data_hash if relation_key == 'author' || already_restored?(data_hash) # create relation objects recursively for all sub-objects relation_definition.each do |sub_relation_key, sub_relation_definition| @@ -169,6 +169,13 @@ module Gitlab @relation_factory.create(relation_factory_params(relation_key, data_hash)) end + # Since we update the data hash in place as we restore relation items, + # and since we also de-duplicate items, we might encounter items that + # have already been restored in a previous iteration. + def already_restored?(relation_item) + !relation_item.is_a?(Hash) + end + def transform_sub_relations!(data_hash, sub_relation_key, sub_relation_definition) sub_data_hash = data_hash[sub_relation_key] return unless sub_data_hash diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ff10baf413f..d988fcd6dff 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8450,6 +8450,9 @@ msgstr "" msgid "For public projects, anyone can view pipelines and access job details (output logs and artifacts)" msgstr "" +msgid "For renewal instructions %{link_start}view our Licensing FAQ.%{link_end}" +msgstr "" + msgid "Forgot your password?" msgstr "" diff --git a/spec/factories/error_tracking/detailed_error.rb b/spec/factories/error_tracking/detailed_error.rb index 07b6c53e3cd..83004ffae38 100644 --- a/spec/factories/error_tracking/detailed_error.rb +++ b/spec/factories/error_tracking/detailed_error.rb @@ -1,41 +1,20 @@ # frozen_string_literal: true FactoryBot.define do - factory :detailed_error_tracking_error, class: 'Gitlab::ErrorTracking::DetailedError' do - id { '1' } - title { 'title' } - type { 'error' } - user_count { 1 } - count { 2 } - first_seen { Time.now.iso8601 } - last_seen { Time.now.iso8601 } - message { 'message' } - culprit { 'culprit' } - external_url { 'http://example.com/id' } + factory :detailed_error_tracking_error, parent: :error_tracking_error, class: 'Gitlab::ErrorTracking::DetailedError' do + gitlab_issue { 'http://gitlab.example.com/issues/1' } external_base_url { 'http://example.com' } - project_id { 'project1' } - project_name { 'project name' } - project_slug { 'project_name' } - short_id { 'ID' } - status { 'unresolved' } + first_release_last_commit { '68c914da9' } + last_release_last_commit { '9ad419c86' } + first_release_short_version { 'abc123' } + last_release_short_version { 'abc123' } + first_release_version { '12345678' } tags do { level: 'error', logger: 'rails' } end - frequency do - [ - [Time.now.to_i, 10] - ] - end - gitlab_issue { 'http://gitlab.example.com/issues/1' } - first_release_last_commit { '68c914da9' } - last_release_last_commit { '9ad419c86' } - first_release_short_version { 'abc123' } - last_release_short_version { 'abc123' } - first_release_version { '12345678' } - skip_create end end diff --git a/spec/factories/error_tracking/error.rb b/spec/factories/error_tracking/error.rb index 5be1f074555..e5f2e2ca9a7 100644 --- a/spec/factories/error_tracking/error.rb +++ b/spec/factories/error_tracking/error.rb @@ -2,13 +2,13 @@ FactoryBot.define do factory :error_tracking_error, class: 'Gitlab::ErrorTracking::Error' do - id { 'id' } + id { '1' } title { 'title' } type { 'error' } user_count { 1 } count { 2 } - first_seen { Time.now } - last_seen { Time.now } + first_seen { Time.now.iso8601 } + last_seen { Time.now.iso8601 } message { 'message' } culprit { 'culprit' } external_url { 'http://example.com/id' } @@ -17,7 +17,11 @@ FactoryBot.define do project_slug { 'project_name' } short_id { 'ID' } status { 'unresolved' } - frequency { [] } + frequency do + [ + [Time.now.to_i, 10] + ] + end skip_create end diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 7126707affd..831bcf8931e 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -32,7 +32,7 @@ describe 'issue move to another project' do let(:new_project) { create(:project) } let(:new_project_search) { create(:project) } let(:text) { "Text with #{mr.to_reference}" } - let(:cross_reference) { old_project.to_reference(new_project) } + let(:cross_reference) { old_project.to_reference_base(new_project) } before do old_project.add_reporter(user) diff --git a/spec/fixtures/lib/gitlab/import_export/with_duplicates.json b/spec/fixtures/lib/gitlab/import_export/with_duplicates.json new file mode 100644 index 00000000000..ed2e1821dd3 --- /dev/null +++ b/spec/fixtures/lib/gitlab/import_export/with_duplicates.json @@ -0,0 +1,43 @@ +{ + "simple": 42, + "duped_hash_with_id": { + "id": 0, + "v1": 1 + }, + "duped_hash_no_id": { + "v1": 1 + }, + "duped_array": [ + "v2" + ], + "array": [ + { + "duped_hash_with_id": { + "id": 0, + "v1": 1 + } + }, + { + "duped_array": [ + "v2" + ] + }, + { + "duped_hash_no_id": { + "v1": 1 + } + } + ], + "nested": { + "duped_hash_with_id": { + "id": 0, + "v1": 1 + }, + "duped_array": [ + "v2" + ], + "array": [ + "don't touch" + ] + } +}
\ No newline at end of file diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 996804f6d08..5b81d034e14 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import Tracking from '~/tracking'; import stubChildren from 'helpers/stub_children'; import component from '~/registry/settings/components/settings_form.vue'; import { createStore } from '~/registry/settings/store/'; @@ -15,6 +16,9 @@ describe('Settings Form', () => { let dispatchSpy; const FORM_ELEMENTS_ID_PREFIX = '#expiration-policy'; + const trackingPayload = { + label: 'docker_container_retention_and_expiration_policies', + }; const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' }; @@ -48,6 +52,7 @@ describe('Settings Form', () => { store.dispatch('setInitialState', stringifiedFormOptions); dispatchSpy = jest.spyOn(store, 'dispatch'); mountComponent(); + jest.spyOn(Tracking, 'event'); }); afterEach(() => { @@ -118,15 +123,23 @@ describe('Settings Form', () => { beforeEach(() => { form = findForm(); }); - it('cancel has type reset', () => { - expect(findCancelButton().attributes('type')).toBe('reset'); - }); - it('form reset event call the appropriate function', () => { - dispatchSpy.mockReturnValue(); - form.trigger('reset'); - // expect.any(Object) is necessary because the event payload is passed to the function - expect(dispatchSpy).toHaveBeenCalledWith('resetSettings', expect.any(Object)); + describe('form cancel event', () => { + it('has type reset', () => { + expect(findCancelButton().attributes('type')).toBe('reset'); + }); + + it('calls the appropriate function', () => { + dispatchSpy.mockReturnValue(); + form.trigger('reset'); + expect(dispatchSpy).toHaveBeenCalledWith('resetSettings'); + }); + + it('tracks the reset event', () => { + dispatchSpy.mockReturnValue(); + form.trigger('reset'); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'reset_form', trackingPayload); + }); }); it('save has type submit', () => { @@ -177,6 +190,12 @@ describe('Settings Form', () => { expect(dispatchSpy).toHaveBeenCalledWith('saveSettings'); }); + it('tracks the submit event', () => { + dispatchSpy.mockResolvedValue(); + form.trigger('submit'); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload); + }); + it('show a success toast when submit succeed', () => { dispatchSpy.mockResolvedValue(); form.trigger('submit'); diff --git a/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb new file mode 100644 index 00000000000..3bb8a5c389d --- /dev/null +++ b/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::ErrorTracking::SentryErrorCollectionResolver do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + + let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') } + + before do + project.add_developer(current_user) + + allow(ErrorTracking::ListIssuesService) + .to receive(:new) + .and_return list_issues_service + end + + describe '#resolve' do + it 'returns an error collection object' do + expect(resolve_error_collection).to be_a Gitlab::ErrorTracking::ErrorCollection + end + + it 'provides the service url' do + fake_url = 'http://test.com' + + expect(list_issues_service) + .to receive(:external_url) + .and_return(fake_url) + + result = resolve_error_collection + expect(result.external_url).to eq fake_url + end + + it 'provides the project' do + expect(resolve_error_collection.project).to eq project + end + end + + private + + def resolve_error_collection(context = { current_user: current_user }) + resolve(described_class, obj: project, args: {}, ctx: context) + end +end diff --git a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb new file mode 100644 index 00000000000..93f89d077d7 --- /dev/null +++ b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::ErrorTracking::SentryErrorsResolver do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:error_collection) { Gitlab::ErrorTracking::ErrorCollection.new(project: project) } + + let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') } + + let(:issues) { nil } + let(:pagination) { nil } + + describe '#resolve' do + context 'insufficient user permission' do + let(:user) { create(:user) } + + it 'returns nil' do + context = { current_user: user } + + expect(resolve_errors({}, context)).to eq nil + end + end + + context 'user with permission' do + before do + project.add_developer(current_user) + + allow(ErrorTracking::ListIssuesService) + .to receive(:new) + .and_return list_issues_service + end + + context 'when after arg given' do + let(:after) { "1576029072000:0:0" } + + it 'gives the cursor arg' do + expect(ErrorTracking::ListIssuesService) + .to receive(:new) + .with(project, current_user, { cursor: after }) + .and_return list_issues_service + + resolve_errors({ after: after }) + end + end + + context 'when no issues fetched' do + before do + allow(list_issues_service) + .to receive(:execute) + .and_return( + issues: nil + ) + end + it 'returns nil' do + expect(resolve_errors).to eq nil + end + end + + context 'when issues returned' do + let(:issues) { [:issue_1, :issue_2] } + let(:pagination) do + { + 'next' => { 'cursor' => 'next' }, + 'previous' => { 'cursor' => 'prev' } + } + end + + before do + allow(list_issues_service) + .to receive(:execute) + .and_return( + issues: issues, + pagination: pagination + ) + end + + it 'sets the issues' do + expect(resolve_errors).to contain_exactly(*issues) + end + + it 'sets the pagination variables' do + result = resolve_errors + expect(result.next_cursor).to eq 'next' + expect(result.previous_cursor).to eq 'prev' + end + + it 'returns an externally paginated array' do + expect(resolve_errors).to be_a Gitlab::Graphql::ExternallyPaginatedArray + end + end + end + end + + private + + def resolve_errors(args = {}, context = { current_user: current_user }) + resolve(described_class, obj: error_collection, args: args, ctx: context) + end +end diff --git a/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb b/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb new file mode 100644 index 00000000000..1e6b7f89c08 --- /dev/null +++ b/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['SentryErrorCollection'] do + it { expect(described_class.graphql_name).to eq('SentryErrorCollection') } + + it { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) } + + it 'exposes the expected fields' do + expected_fields = %i[ + errors + detailed_error + external_url + ] + + is_expected.to have_graphql_fields(*expected_fields) + end + + describe 'errors field' do + subject { described_class.fields['errors'] } + + it 'returns errors' do + aggregate_failures 'testing the correct types are returned' do + is_expected.to have_graphql_type(Types::ErrorTracking::SentryErrorType.connection_type) + is_expected.to have_graphql_extension(Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension) + is_expected.to have_graphql_resolver(Resolvers::ErrorTracking::SentryErrorsResolver) + end + end + end +end diff --git a/spec/graphql/types/error_tracking/sentry_error_type_spec.rb b/spec/graphql/types/error_tracking/sentry_error_type_spec.rb new file mode 100644 index 00000000000..51acd035024 --- /dev/null +++ b/spec/graphql/types/error_tracking/sentry_error_type_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['SentryError'] do + it { expect(described_class.graphql_name).to eq('SentryError') } + + it 'exposes the expected fields' do + expected_fields = %i[ + id + sentryId + title + type + userCount + count + firstSeen + lastSeen + message + culprit + externalUrl + sentryProjectId + sentryProjectName + sentryProjectSlug + shortId + status + frequency + ] + + is_expected.to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb index a82b890be42..5cfb0e6e6f7 100644 --- a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb @@ -229,10 +229,10 @@ describe Banzai::Filter::CommitRangeReferenceFilter do end it 'ignores invalid commit IDs on the referenced project' do - exp = act = "Fixed #{project2.to_reference}@#{commit1.id.reverse}...#{commit2.id}" + exp = act = "Fixed #{project2.to_reference_base}@#{commit1.id.reverse}...#{commit2.id}" expect(reference_filter(act).to_html).to eq exp - exp = act = "Fixed #{project2.to_reference}@#{commit1.id}...#{commit2.id.reverse}" + exp = act = "Fixed #{project2.to_reference_base}@#{commit1.id}...#{commit2.id.reverse}" expect(reference_filter(act).to_html).to eq exp end end diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb index 66af26bc51c..82df5064896 100644 --- a/spec/lib/banzai/filter/label_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb @@ -369,7 +369,7 @@ describe Banzai::Filter::LabelReferenceFilter do end context 'with project reference' do - let(:reference) { "#{project.to_reference}#{group_label.to_reference(format: :name)}" } + let(:reference) { "#{project.to_reference_base}#{group_label.to_reference(format: :name)}" } it 'links to a valid reference' do doc = reference_filter("See #{reference}", project: project) @@ -385,7 +385,7 @@ describe Banzai::Filter::LabelReferenceFilter do end it 'ignores invalid label names' do - exp = act = %(Label #{project.to_reference}#{Label.reference_prefix}"#{group_label.name.reverse}") + exp = act = %(Label #{project.to_reference_base}#{Label.reference_prefix}"#{group_label.name.reverse}") expect(reference_filter(act).to_html).to eq exp end diff --git a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb index 2fe8c9074df..0c8413adcba 100644 --- a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb @@ -367,15 +367,17 @@ describe Banzai::Filter::MilestoneReferenceFilter do expect(doc.css('a').first.text).to eq(urls.milestone_url(milestone)) end - it 'does not support cross-project references' do + it 'does not support cross-project references', :aggregate_failures do another_group = create(:group) another_project = create(:project, :public, group: group) - project_reference = another_project.to_reference(project) + project_reference = another_project.to_reference_base(project) + input_text = "See #{project_reference}#{reference}" milestone.update!(group: another_group) - doc = reference_filter("See #{project_reference}#{reference}") + doc = reference_filter(input_text) + expect(input_text).to match(Milestone.reference_pattern) expect(doc.css('a')).to be_empty end diff --git a/spec/lib/banzai/filter/project_reference_filter_spec.rb b/spec/lib/banzai/filter/project_reference_filter_spec.rb index d0b4542d503..a054b79ec03 100644 --- a/spec/lib/banzai/filter/project_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/project_reference_filter_spec.rb @@ -10,7 +10,7 @@ describe Banzai::Filter::ProjectReferenceFilter do end def get_reference(project) - project.to_reference_with_postfix + project.to_reference end let(:project) { create(:project, :public) } diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb index b0d2e049777..a3904f4a97c 100644 --- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb @@ -8,7 +8,7 @@ describe Gitlab::Gfm::ReferenceRewriter do let(:new_project) { create(:project, name: 'new-project', group: group) } let(:user) { create(:user) } - let(:old_project_ref) { old_project.to_reference(new_project) } + let(:old_project_ref) { old_project.to_reference_base(new_project) } let(:text) { 'some text' } before do diff --git a/spec/lib/gitlab/import_export/project_tree_loader_spec.rb b/spec/lib/gitlab/import_export/project_tree_loader_spec.rb new file mode 100644 index 00000000000..b22de5a3f7b --- /dev/null +++ b/spec/lib/gitlab/import_export/project_tree_loader_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::ProjectTreeLoader do + let(:fixture) { 'spec/fixtures/lib/gitlab/import_export/with_duplicates.json' } + let(:project_tree) { JSON.parse(File.read(fixture)) } + + context 'without de-duplicating entries' do + let(:parsed_tree) do + subject.load(fixture) + end + + it 'parses the JSON into the expected tree' do + expect(parsed_tree).to eq(project_tree) + end + + it 'does not de-duplicate entries' do + expect(parsed_tree['duped_hash_with_id']).not_to be(parsed_tree['array'][0]['duped_hash_with_id']) + end + end + + context 'with de-duplicating entries' do + let(:parsed_tree) do + subject.load(fixture, dedup_entries: true) + end + + it 'parses the JSON into the expected tree' do + expect(parsed_tree).to eq(project_tree) + end + + it 'de-duplicates equal values' do + expect(parsed_tree['duped_hash_with_id']).to be(parsed_tree['array'][0]['duped_hash_with_id']) + expect(parsed_tree['duped_hash_with_id']).to be(parsed_tree['nested']['duped_hash_with_id']) + expect(parsed_tree['duped_array']).to be(parsed_tree['array'][1]['duped_array']) + expect(parsed_tree['duped_array']).to be(parsed_tree['nested']['duped_array']) + end + + it 'does not de-duplicate hashes without IDs' do + expect(parsed_tree['duped_hash_no_id']).to eq(parsed_tree['array'][2]['duped_hash_no_id']) + expect(parsed_tree['duped_hash_no_id']).not_to be(parsed_tree['array'][2]['duped_hash_no_id']) + end + + it 'keeps single entries intact' do + expect(parsed_tree['simple']).to eq(42) + expect(parsed_tree['nested']['array']).to eq(["don't touch"]) + end + end +end diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 25f70420cda..129f119e148 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -450,7 +450,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do context 'project.json file access check' do let(:user) { create(:user) } let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') } - let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) } + let(:project_tree_restorer) do + described_class.new(user: user, shared: shared, project: project) + end let(:restored_project_json) { project_tree_restorer.restore } it 'does not read a symlink' do @@ -725,7 +727,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do let(:project) { create(:project) } let(:user) { create(:user) } let(:tree_hash) { { 'visibility_level' => visibility } } - let(:restorer) { described_class.new(user: user, shared: shared, project: project) } + let(:restorer) do + described_class.new(user: user, shared: shared, project: project) + end before do expect(restorer).to receive(:read_tree_hash) { tree_hash } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a1c38a3e668..df32545b90b 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -131,23 +131,19 @@ describe Project do end context 'when creating a new project' do - it 'automatically creates a CI/CD settings row' do - project = create(:project) + let_it_be(:project) { create(:project) } + it 'automatically creates a CI/CD settings row' do expect(project.ci_cd_settings).to be_an_instance_of(ProjectCiCdSetting) expect(project.ci_cd_settings).to be_persisted end it 'automatically creates a container expiration policy row' do - project = create(:project) - expect(project.container_expiration_policy).to be_an_instance_of(ContainerExpirationPolicy) expect(project.container_expiration_policy).to be_persisted end it 'automatically creates a Pages metadata row' do - project = create(:project) - expect(project.pages_metadatum).to be_an_instance_of(ProjectPagesMetadatum) expect(project.pages_metadatum).to be_persisted end @@ -532,111 +528,114 @@ describe Project do it { is_expected.to delegate_method(:last_pipeline).to(:commit).with_arguments(allow_nil: true) } end - describe '#to_reference_with_postfix' do - it 'returns the full path with reference_postfix' do - namespace = create(:namespace, path: 'sample-namespace') - project = create(:project, path: 'sample-project', namespace: namespace) - - expect(project.to_reference_with_postfix).to eq 'sample-namespace/sample-project>' - end - end + describe 'reference methods' do + let_it_be(:owner) { create(:user, name: 'Gitlab') } + let_it_be(:namespace) { create(:namespace, name: 'Sample namespace', path: 'sample-namespace', owner: owner) } + let_it_be(:project) { create(:project, name: 'Sample project', path: 'sample-project', namespace: namespace) } + let_it_be(:group) { create(:group, name: 'Group', path: 'sample-group') } + let_it_be(:another_project) { create(:project, namespace: namespace) } + let_it_be(:another_namespace_project) { create(:project, name: 'another-project') } - describe '#to_reference' do - let(:owner) { create(:user, name: 'Gitlab') } - let(:namespace) { create(:namespace, path: 'sample-namespace', owner: owner) } - let(:project) { create(:project, path: 'sample-project', namespace: namespace) } - let(:group) { create(:group, name: 'Group', path: 'sample-group') } + describe '#to_reference' do + it 'returns the path with reference_postfix' do + expect(project.to_reference).to eq("#{project.full_path}>") + end - context 'when nil argument' do - it 'returns nil' do - expect(project.to_reference).to be_nil + it 'returns the path with reference_postfix when arg is self' do + expect(project.to_reference(project)).to eq("#{project.full_path}>") end - end - context 'when full is true' do - it 'returns complete path to the project' do - expect(project.to_reference(full: true)).to eq 'sample-namespace/sample-project' - expect(project.to_reference(project, full: true)).to eq 'sample-namespace/sample-project' - expect(project.to_reference(group, full: true)).to eq 'sample-namespace/sample-project' + it 'returns the full_path with reference_postfix when full' do + expect(project.to_reference(full: true)).to eq("#{project.full_path}>") end - end - context 'when same project argument' do - it 'returns nil' do - expect(project.to_reference(project)).to be_nil + it 'returns the full_path with reference_postfix when cross-project' do + expect(project.to_reference(build_stubbed(:project))).to eq("#{project.full_path}>") end end - context 'when cross namespace project argument' do - let(:another_namespace_project) { create(:project, name: 'another-project') } - - it 'returns complete path to the project' do - expect(project.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project' + describe '#to_reference_base' do + context 'when nil argument' do + it 'returns nil' do + expect(project.to_reference_base).to be_nil + end end - end - context 'when same namespace / cross-project argument' do - let(:another_project) { create(:project, namespace: namespace) } + context 'when full is true' do + it 'returns complete path to the project', :aggregate_failures do + be_full_path = eq('sample-namespace/sample-project') - it 'returns path to the project' do - expect(project.to_reference(another_project)).to eq 'sample-project' + expect(project.to_reference_base(full: true)).to be_full_path + expect(project.to_reference_base(project, full: true)).to be_full_path + expect(project.to_reference_base(group, full: true)).to be_full_path + end end - end - context 'when different namespace / cross-project argument' do - let(:another_namespace) { create(:namespace, path: 'another-namespace', owner: owner) } - let(:another_project) { create(:project, path: 'another-project', namespace: another_namespace) } + context 'when same project argument' do + it 'returns nil' do + expect(project.to_reference_base(project)).to be_nil + end + end - it 'returns full path to the project' do - expect(project.to_reference(another_project)).to eq 'sample-namespace/sample-project' + context 'when cross namespace project argument' do + it 'returns complete path to the project' do + expect(project.to_reference_base(another_namespace_project)).to eq 'sample-namespace/sample-project' + end end - end - context 'when argument is a namespace' do - context 'with same project path' do + context 'when same namespace / cross-project argument' do it 'returns path to the project' do - expect(project.to_reference(namespace)).to eq 'sample-project' + expect(project.to_reference_base(another_project)).to eq 'sample-project' end end - context 'with different project path' do + context 'when different namespace / cross-project argument with same owner' do + let(:another_namespace_same_owner) { create(:namespace, path: 'another-namespace', owner: owner) } + let(:another_project_same_owner) { create(:project, path: 'another-project', namespace: another_namespace_same_owner) } + it 'returns full path to the project' do - expect(project.to_reference(group)).to eq 'sample-namespace/sample-project' + expect(project.to_reference_base(another_project_same_owner)).to eq 'sample-namespace/sample-project' end end - end - end - describe '#to_human_reference' do - let(:owner) { create(:user, name: 'Gitlab') } - let(:namespace) { create(:namespace, name: 'Sample namespace', owner: owner) } - let(:project) { create(:project, name: 'Sample project', namespace: namespace) } + context 'when argument is a namespace' do + context 'with same project path' do + it 'returns path to the project' do + expect(project.to_reference_base(namespace)).to eq 'sample-project' + end + end - context 'when nil argument' do - it 'returns nil' do - expect(project.to_human_reference).to be_nil + context 'with different project path' do + it 'returns full path to the project' do + expect(project.to_reference_base(group)).to eq 'sample-namespace/sample-project' + end + end end end - context 'when same project argument' do - it 'returns nil' do - expect(project.to_human_reference(project)).to be_nil + describe '#to_human_reference' do + context 'when nil argument' do + it 'returns nil' do + expect(project.to_human_reference).to be_nil + end end - end - - context 'when cross namespace project argument' do - let(:another_namespace_project) { create(:project, name: 'another-project') } - it 'returns complete name with namespace of the project' do - expect(project.to_human_reference(another_namespace_project)).to eq 'Gitlab / Sample project' + context 'when same project argument' do + it 'returns nil' do + expect(project.to_human_reference(project)).to be_nil + end end - end - context 'when same namespace / cross-project argument' do - let(:another_project) { create(:project, namespace: namespace) } + context 'when cross namespace project argument' do + it 'returns complete name with namespace of the project' do + expect(project.to_human_reference(another_namespace_project)).to eq 'Gitlab / Sample project' + end + end - it 'returns name of the project' do - expect(project.to_human_reference(another_project)).to eq 'Sample project' + context 'when same namespace / cross-project argument' do + it 'returns name of the project' do + expect(project.to_human_reference(another_project)).to eq 'Sample project' + end end end end diff --git a/spec/presenters/sentry_detailed_error_presenter_spec.rb b/spec/presenters/sentry_error_presenter_spec.rb index e483b6d41a1..5f3f1d33b86 100644 --- a/spec/presenters/sentry_detailed_error_presenter_spec.rb +++ b/spec/presenters/sentry_error_presenter_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe SentryDetailedErrorPresenter do +describe SentryErrorPresenter do let(:error) { build(:detailed_error_tracking_error) } let(:presenter) { described_class.new(error) } @@ -10,7 +10,7 @@ describe SentryDetailedErrorPresenter do subject { presenter.frequency } it 'returns an array of frequency structs' do - expect(subject).to include(a_kind_of(SentryDetailedErrorPresenter::FrequencyStruct)) + expect(subject).to include(a_kind_of(SentryErrorPresenter::FrequencyStruct)) end it 'converts the times into UTC time objects' do diff --git a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb new file mode 100644 index 00000000000..e68025bf01b --- /dev/null +++ b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe 'sentry errors requests' do + include GraphqlHelpers + let_it_be(:project) { create(:project, :repository) } + let_it_be(:project_setting) { create(:project_error_tracking_setting, project: project) } + let_it_be(:current_user) { project.owner } + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('sentryErrors', {}, fields) + ) + end + + describe 'getting a detailed sentry error' do + let_it_be(:sentry_detailed_error) { build(:detailed_error_tracking_error) } + let(:sentry_gid) { sentry_detailed_error.to_global_id.to_s } + + let(:detailed_fields) do + all_graphql_fields_for('SentryDetailedError'.classify) + end + + let(:fields) do + query_graphql_field('detailedError', { id: sentry_gid }, detailed_fields) + end + + let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'detailedError') } + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: current_user) + end + end + + context 'when data is loading via reactive cache' do + before do + post_graphql(query, current_user: current_user) + end + + it "is expected to return an empty error" do + expect(error_data).to eq nil + end + end + + context 'reactive cache returns data' do + before do + allow_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) + .to receive(:issue_details) + .and_return({ issue: sentry_detailed_error }) + + post_graphql(query, current_user: current_user) + end + + let(:sentry_error) { sentry_detailed_error } + let(:error) { error_data } + + it_behaves_like 'setting sentry error data' + + it 'is expected to return the frequency correctly' do + aggregate_failures 'it returns the frequency correctly' do + expect(error_data['frequency'].count).to eql sentry_detailed_error.frequency.count + + first_frequency = error_data['frequency'].first + expect(Time.parse(first_frequency['time'])).to eql Time.at(sentry_detailed_error.frequency[0][0], in: 0) + expect(first_frequency['count']).to eql sentry_detailed_error.frequency[0][1] + end + end + + context 'user does not have permission' do + let(:current_user) { create(:user) } + + it "is expected to return an empty error" do + expect(error_data).to eq nil + end + end + end + + context 'sentry api returns an error' do + before do + expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) + .to receive(:issue_details) + .and_return({ error: 'error message' }) + + post_graphql(query, current_user: current_user) + end + + it 'is expected to handle the error and return nil' do + expect(error_data).to eq nil + end + end + end + + describe 'getting an errors list' do + let_it_be(:sentry_error) { build(:error_tracking_error) } + let_it_be(:pagination) do + { + 'next' => { 'cursor' => '2222' }, + 'previous' => { 'cursor' => '1111' } + } + end + + let(:fields) do + <<~QUERY + errors { + nodes { + #{all_graphql_fields_for('SentryError'.classify)} + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + QUERY + end + + let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'errors', 'nodes') } + let(:pagination_data) { graphql_data.dig('project', 'sentryErrors', 'errors', 'pageInfo') } + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: current_user) + end + end + + context 'when data is loading via reactive cache' do + before do + post_graphql(query, current_user: current_user) + end + + it "is expected to return nil" do + expect(error_data).to eq nil + end + end + + context 'reactive cache returns data' do + before do + expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) + .to receive(:list_sentry_issues) + .and_return({ issues: [sentry_error], pagination: pagination }) + + post_graphql(query, current_user: current_user) + end + + let(:error) { error_data.first } + + it 'is expected to return an array of data' do + expect(error_data).to be_a Array + expect(error_data.count).to eq 1 + end + + it_behaves_like 'setting sentry error data' + + it 'sets the pagination correctly' do + expect(pagination_data['startCursor']).to eq(pagination['previous']['cursor']) + expect(pagination_data['endCursor']).to eq(pagination['next']['cursor']) + end + + it 'is expected to return the frequency correctly' do + aggregate_failures 'it returns the frequency correctly' do + error = error_data.first + + expect(error['frequency'].count).to eql sentry_error.frequency.count + + first_frequency = error['frequency'].first + + expect(Time.parse(first_frequency['time'])).to eql Time.at(sentry_error.frequency[0][0], in: 0) + expect(first_frequency['count']).to eql sentry_error.frequency[0][1] + end + end + end + + context "sentry api itself errors out" do + before do + expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) + .to receive(:list_sentry_issues) + .and_return({ error: 'error message' }) + + post_graphql(query, current_user: current_user) + end + + it 'is expected to handle the error and return nil' do + expect(error_data).to eq nil + end + end + end +end diff --git a/spec/requests/self_monitoring_project_spec.rb b/spec/requests/self_monitoring_project_spec.rb index 1da0be882d0..5e46645e7a0 100644 --- a/spec/requests/self_monitoring_project_spec.rb +++ b/spec/requests/self_monitoring_project_spec.rb @@ -68,6 +68,8 @@ describe 'Self-Monitoring project requests' do let(:job_id) { nil } it 'returns bad_request' do + create(:application_setting) + subject aggregate_failures do @@ -81,11 +83,10 @@ describe 'Self-Monitoring project requests' do end context 'when self-monitoring project exists' do - let(:project) { build(:project) } + let(:project) { create(:project) } before do - stub_application_setting(self_monitoring_project_id: 1) - stub_application_setting(self_monitoring_project: project) + create(:application_setting, self_monitoring_project_id: project.id) end it 'does not need job_id' do @@ -94,7 +95,7 @@ describe 'Self-Monitoring project requests' do aggregate_failures do expect(response).to have_gitlab_http_status(:success) expect(json_response).to eq( - 'project_id' => 1, + 'project_id' => project.id, 'project_full_path' => project.full_path ) end @@ -106,7 +107,7 @@ describe 'Self-Monitoring project requests' do aggregate_failures do expect(response).to have_gitlab_http_status(:success) expect(json_response).to eq( - 'project_id' => 1, + 'project_id' => project.id, 'project_full_path' => project.full_path ) end @@ -179,7 +180,7 @@ describe 'Self-Monitoring project requests' do context 'when self-monitoring project exists and job does not exist' do before do - stub_application_setting(self_monitoring_project_id: 1) + create(:application_setting, self_monitoring_project_id: create(:project).id) end it 'returns bad_request' do @@ -196,6 +197,10 @@ describe 'Self-Monitoring project requests' do end context 'when self-monitoring project does not exist' do + before do + create(:application_setting) + end + it 'does not need job_id' do get status_delete_self_monitoring_project_admin_application_settings_path diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index e151a934591..31b0290bb15 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -108,6 +108,12 @@ RSpec::Matchers.define :have_graphql_resolver do |expected| end end +RSpec::Matchers.define :have_graphql_extension do |expected| + match do |field| + expect(field.metadata[:type_class].extensions).to include(expected) + end +end + RSpec::Matchers.define :expose_permissions_using do |expected| match do |type| permission_field = type.fields['userPermissions'] diff --git a/spec/support/shared_examples/error_tracking_shared_examples.rb b/spec/support/shared_examples/error_tracking_shared_examples.rb new file mode 100644 index 00000000000..86134fa7fd1 --- /dev/null +++ b/spec/support/shared_examples/error_tracking_shared_examples.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'setting sentry error data' do + it 'sets the sentry error data correctly' do + aggregate_failures 'testing the sentry error is correct' do + expect(error['id']).to eql sentry_error.to_global_id.to_s + expect(error['sentryId']).to eql sentry_error.id.to_s + expect(error['status']).to eql sentry_error.status.upcase + expect(error['firstSeen']).to eql sentry_error.first_seen + expect(error['lastSeen']).to eql sentry_error.last_seen + end + end +end |