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