Commit 7cc68724 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 46b10c0f
/* 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));
......
......@@ -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') }}
......
......@@ -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
......
......@@ -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.')
......
......@@ -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
# 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
# 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
......@@ -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
# 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
# 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
......@@ -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
......
......@@ -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
......
......@@ -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?
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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)
......
......@@ -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
......
# frozen_string_literal: true
module ErrorTracking
class DetailedErrorPolicy < BasePolicy
class BasePolicy < ::BasePolicy
delegate { @subject.gitlab_project }
end
end
# 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')
......
# 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
# 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
---
title: Add querying of Sentry errors to Graphql
merge_request: 21802
author:
type: added
---
title: refactoring gl_dropdown.js to use ES6 classes instead of constructor functions
merge_request: 20488
author: nuwe1
type: other
---
title: Add license FAQ link to license expired message
merge_request:
author:
type: added
......@@ -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
......
......@@ -5453,6 +5453,11 @@ type Project {
id: ID!
): SentryDetailedError
"""
Paginated collection of Sentry errors on the project
"""
sentryErrors: SentryErrorCollection
"""
E-mail address of the service desk.
"""
......@@ -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
......
......@@ -1433,6 +1433,20 @@
"isDeprecated": false,
"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.",
......@@ -16708,7 +16722,7 @@
{
"kind": "OBJECT",
"name": "SentryDetailedError",
"description": null,
"description": "A Sentry error.",
"fields": [
{
"name": "count",
......@@ -17408,6 +17422,568 @@
"enumValues": null,
"possibleTypes": null
},
{
"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",
......
......@@ -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 |
......
......@@ -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".
......
......@@ -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
......
......@@ -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
......
......@@ -35,7 +35,7 @@ module Gitlab
:user_count
def self.declarative_policy_class
'ErrorTracking::DetailedErrorPolicy'
'ErrorTracking::BasePolicy'
end
end
end
......
......@@ -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
# 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
# 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
# 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
......@@ -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')
......
......@@ -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
......
......@@ -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 ""
......
# 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
......@@ -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
......
......@@ -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)
......
{
"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
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');
......
# 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
# 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
# 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
# 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
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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) }
......
......@@ -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
......
# 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
......@@ -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 }
......
......@@ -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
......
......@@ -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
......
# 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
......@@ -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
......
......@@ -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']
......
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment