Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-07-27 18:09:54 +00:00
parent e69aae81ea
commit 03fbe61813
47 changed files with 1468 additions and 469 deletions

View file

@ -9,6 +9,7 @@ require:
inherit_from:
- .rubocop_todo.yml
- ./rubocop/rubocop-migrations.yml
- ./rubocop/rubocop-usage-data.yml
inherit_mode:
merge:

View file

@ -14,7 +14,7 @@ export default () => {
new ProtectedTagEditList();
initDeployKeys();
initSettingsPanels();
new ProtectedBranchCreate();
new ProtectedBranchCreate({ hasLicense: false });
new ProtectedBranchEditList();
new DueDateSelectors();
fileUpload('.js-choose-file', '.js-object-map-input');

View file

@ -0,0 +1,524 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
import { escape, find, countBy } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
import { n__, s__, __ } from '~/locale';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants';
export default class AccessDropdown {
constructor(options) {
const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options;
this.options = options;
this.hasLicense = hasLicense;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData.roles;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/-/autocomplete/users.json';
this.groupsPath = '/-/autocomplete/project_groups.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
this.persistPreselectedItems();
this.noOneObj = this.accessLevelsData.find(level => level.id === ACCESS_LEVEL_NONE);
this.initDropdown();
}
initDropdown() {
const { onSelect, onHide } = this.options;
this.$dropdown.glDropdown({
data: this.getData.bind(this),
selectable: true,
filterable: true,
filterRemote: true,
multiSelect: this.$dropdown.hasClass('js-multiselect'),
renderRow: this.renderRow.bind(this),
toggleLabel: this.toggleLabel.bind(this),
hidden() {
if (onHide) {
onHide();
}
},
clicked: options => {
const { $el, e } = options;
const item = options.selectedObj;
e.preventDefault();
if (!this.hasLicense) {
// We're not multiselecting quite yet with FOSS:
// remove all preselected items before selecting this item
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37499
this.accessLevelsData.forEach(level => {
this.removeSelectedItem(level);
});
}
if ($el.is('.is-active')) {
if (this.noOneObj) {
if (item.id === this.noOneObj.id && this.hasLicense) {
// remove all others selected items
this.accessLevelsData.forEach(level => {
if (level.id !== item.id) {
this.removeSelectedItem(level);
}
});
// remove selected item visually
this.$wrap.find(`.item-${item.type}`).removeClass('is-active');
} else {
const $noOne = this.$wrap.find(
`.is-active.item-${item.type}[data-role-id="${this.noOneObj.id}"]`,
);
if ($noOne.length) {
$noOne.removeClass('is-active');
this.removeSelectedItem(this.noOneObj);
}
}
}
// make element active right away
$el.addClass(`is-active item-${item.type}`);
// Add "No one"
this.addSelectedItem(item);
} else {
this.removeSelectedItem(item);
}
if (onSelect) {
onSelect(item, $el, this);
}
},
});
this.$dropdown.find('.dropdown-toggle-text').text(this.toggleLabel());
}
persistPreselectedItems() {
const itemsToPreselect = this.$dropdown.data('preselectedItems');
if (!itemsToPreselect || !itemsToPreselect.length) {
return;
}
const persistedItems = itemsToPreselect.map(item => {
const persistedItem = { ...item };
persistedItem.persisted = true;
return persistedItem;
});
this.setSelectedItems(persistedItems);
}
setSelectedItems(items = []) {
this.items = items;
}
getSelectedItems() {
return this.items.filter(item => !item._destroy);
}
getAllSelectedItems() {
return this.items;
}
// Return dropdown as input data ready to submit
getInputData() {
const selectedItems = this.getAllSelectedItems();
const accessLevels = selectedItems.map(item => {
const obj = {};
if (typeof item.id !== 'undefined') {
obj.id = item.id;
}
if (typeof item._destroy !== 'undefined') {
obj._destroy = item._destroy;
}
if (item.type === LEVEL_TYPES.ROLE) {
obj.access_level = item.access_level;
} else if (item.type === LEVEL_TYPES.USER) {
obj.user_id = item.user_id;
} else if (item.type === LEVEL_TYPES.GROUP) {
obj.group_id = item.group_id;
}
return obj;
});
return accessLevels;
}
addSelectedItem(selectedItem) {
let itemToAdd = {};
let index = -1;
let alreadyAdded = false;
const selectedItems = this.getAllSelectedItems();
// Compare IDs based on selectedItem.type
selectedItems.forEach((item, i) => {
let comparator;
switch (selectedItem.type) {
case LEVEL_TYPES.ROLE:
comparator = LEVEL_ID_PROP.ROLE;
// If the item already exists, just use it
if (item[comparator] === selectedItem.id) {
alreadyAdded = true;
}
break;
case LEVEL_TYPES.GROUP:
comparator = LEVEL_ID_PROP.GROUP;
break;
case LEVEL_TYPES.USER:
comparator = LEVEL_ID_PROP.USER;
break;
default:
break;
}
if (selectedItem.id === item[comparator]) {
index = i;
}
});
if (alreadyAdded) {
return;
}
if (index !== -1 && selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
return;
}
itemToAdd.type = selectedItem.type;
if (selectedItem.type === LEVEL_TYPES.USER) {
itemToAdd = {
user_id: selectedItem.id,
name: selectedItem.name || '_name1',
username: selectedItem.username || '_username1',
avatar_url: selectedItem.avatar_url || '_avatar_url1',
type: LEVEL_TYPES.USER,
};
} else if (selectedItem.type === LEVEL_TYPES.ROLE) {
itemToAdd = {
access_level: selectedItem.id,
type: LEVEL_TYPES.ROLE,
};
} else if (selectedItem.type === LEVEL_TYPES.GROUP) {
itemToAdd = {
group_id: selectedItem.id,
type: LEVEL_TYPES.GROUP,
};
}
this.items.push(itemToAdd);
}
removeSelectedItem(itemToDelete) {
let index = -1;
const selectedItems = this.getAllSelectedItems();
// To find itemToDelete on selectedItems, first we need the index
selectedItems.every((item, i) => {
if (item.type !== itemToDelete.type) {
return true;
}
if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) {
index = i;
} else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) {
index = i;
} else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) {
index = i;
}
// Break once we have index set
return !(index > -1);
});
// if ItemToDelete is not really selected do nothing
if (index === -1) {
return;
}
if (selectedItems[index].persisted) {
// If we toggle an item that has been already marked with _destroy
if (selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
} else {
selectedItems[index]._destroy = '1';
}
} else {
selectedItems.splice(index, 1);
}
}
toggleLabel() {
const currentItems = this.getSelectedItems();
const $dropdownToggleText = this.$dropdown.find('.dropdown-toggle-text');
if (currentItems.length === 0) {
$dropdownToggleText.addClass('is-default');
return this.defaultLabel;
}
$dropdownToggleText.removeClass('is-default');
if (currentItems.length === 1 && currentItems[0].type === LEVEL_TYPES.ROLE) {
const roleData = this.accessLevelsData.find(data => data.id === currentItems[0].access_level);
return roleData.text;
}
const labelPieces = [];
const counts = countBy(currentItems, item => item.type);
if (counts[LEVEL_TYPES.ROLE] > 0) {
labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE]));
}
if (counts[LEVEL_TYPES.USER] > 0) {
labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
}
if (counts[LEVEL_TYPES.GROUP] > 0) {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
}
return labelPieces.join(', ');
}
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
this.getUsers(query),
this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
])
.then(([usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
callback(this.consolidateData(usersResponse.data, groupsResponse.data));
})
.catch(() => Flash(__('Failed to load groups & users.')));
} else {
callback(this.consolidateData());
}
}
consolidateData(usersResponse = [], groupsResponse = []) {
let consolidatedData = [];
// ID property is handled differently locally from the server
//
// For Groups
// In dropdown: `id`
// For submit: `group_id`
//
// For Roles
// In dropdown: `id`
// For submit: `access_level`
//
// For Users
// In dropdown: `id`
// For submit: `user_id`
/*
* Build roles
*/
const roles = this.accessLevelsData.map(level => {
/* eslint-disable no-param-reassign */
// This re-assignment is intentional as
// level.type property is being used in removeSelectedItem()
// for comparision, and accessLevelsData is provided by
// gon.create_access_levels which doesn't have `type` included.
// See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823
level.type = LEVEL_TYPES.ROLE;
return level;
});
if (roles.length) {
consolidatedData = consolidatedData.concat(
[{ type: 'header', content: s__('AccessDropdown|Roles') }],
roles,
);
}
if (this.hasLicense) {
const map = [];
const selectedItems = this.getSelectedItems();
/*
* Build groups
*/
const groups = groupsResponse.map(group => ({
...group,
type: LEVEL_TYPES.GROUP,
}));
/*
* Build users
*/
const users = selectedItems
.filter(item => item.type === LEVEL_TYPES.USER)
.map(item => {
// Save identifiers for easy-checking more later
map.push(LEVEL_TYPES.USER + item.user_id);
return {
id: item.user_id,
name: item.name,
username: item.username,
avatar_url: item.avatar_url,
type: LEVEL_TYPES.USER,
};
});
// Has to be checked against server response
// because the selected item can be in filter results
usersResponse.forEach(response => {
// Add is it has not been added
if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) {
const user = { ...response };
user.type = LEVEL_TYPES.USER;
users.push(user);
}
});
if (groups.length) {
if (roles.length) {
consolidatedData = consolidatedData.concat([{ type: 'divider' }]);
}
consolidatedData = consolidatedData.concat(
[{ type: 'header', content: s__('AccessDropdown|Groups') }],
groups,
);
}
if (users.length) {
consolidatedData = consolidatedData.concat(
[{ type: 'divider' }],
[{ type: 'header', content: s__('AccessDropdown|Users') }],
users,
);
}
}
return consolidatedData;
}
getUsers(query) {
return axios.get(this.buildUrl(gon.relative_url_root, this.usersPath), {
params: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
}
getGroups() {
return axios.get(this.buildUrl(gon.relative_url_root, this.groupsPath), {
params: {
project_id: gon.current_project_id,
},
});
}
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot != null) {
newUrl = urlRoot.replace(/\/$/, '') + url;
}
return newUrl;
}
renderRow(item) {
let criteria = {};
let groupRowEl;
// Dectect if the current item is already saved so we can add
// the `is-active` class so the item looks as marked
switch (item.type) {
case LEVEL_TYPES.USER:
criteria = { user_id: item.id };
break;
case LEVEL_TYPES.ROLE:
criteria = { access_level: item.id };
break;
case LEVEL_TYPES.GROUP:
criteria = { group_id: item.id };
break;
default:
break;
}
const isActive = find(this.getSelectedItems(), criteria) ? 'is-active' : '';
switch (item.type) {
case LEVEL_TYPES.USER:
groupRowEl = this.userRowHtml(item, isActive);
break;
case LEVEL_TYPES.ROLE:
groupRowEl = this.roleRowHtml(item, isActive);
break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
break;
default:
groupRowEl = '';
break;
}
return groupRowEl;
}
userRowHtml(user, isActive) {
const isActiveClass = isActive || '';
return `
<li>
<a href="#" class="${isActiveClass}">
<img src="${user.avatar_url}" class="avatar avatar-inline" width="30">
<strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong>
<span class="dropdown-menu-user-username">${user.username}</span>
</a>
</li>
`;
}
groupRowHtml(group, isActive) {
const isActiveClass = isActive || '';
const avatarEl = group.avatar_url
? `<img src="${group.avatar_url}" class="avatar avatar-inline" width="30">`
: '';
return `
<li>
<a href="#" class="${isActiveClass}">
${avatarEl}
<span class="dropdown-menu-group-groupname">${group.name}</span>
</a>
</li>
`;
}
roleRowHtml(role, isActive) {
const isActiveClass = isActive || '';
return `
<li>
<a href="#" class="${isActiveClass} item-${role.type}" data-role-id="${role.id}">
${role.text}
</a>
</li>
`;
}
}

View file

@ -0,0 +1,13 @@
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
GROUP: 'group_id',
};
export const ACCESS_LEVEL_NONE = 0;

View file

@ -0,0 +1,18 @@
export const ACCESS_LEVELS = {
MERGE: 'merge_access_levels',
PUSH: 'push_access_levels',
};
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
GROUP: 'group_id',
};
export const ACCESS_LEVEL_NONE = 0;

View file

@ -1,28 +0,0 @@
import { __ } from '~/locale';
export default class ProtectedBranchAccessDropdown {
constructor(options) {
this.options = options;
this.initDropdown();
}
initDropdown() {
const { $dropdown, data, onSelect } = this.options;
$dropdown.glDropdown({
data,
selectable: true,
inputId: $dropdown.data('inputId'),
fieldName: $dropdown.data('fieldName'),
toggleLabel(item, $el) {
if ($el.is('.is-active')) {
return item.text;
}
return __('Select');
},
clicked(options) {
options.e.preventDefault();
onSelect();
},
});
}
}

View file

@ -1,41 +1,62 @@
import $ from 'jquery';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor';
import AccessDropdown from '~/projects/settings/access_dropdown';
import axios from '~/lib/utils/axios_utils';
import AccessorUtilities from '~/lib/utils/accessor';
import Flash from '~/flash';
import CreateItemDropdown from '~/create_item_dropdown';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
import { __ } from '~/locale';
export default class ProtectedBranchCreate {
constructor() {
constructor(options) {
this.hasLicense = options.hasLicense;
this.$form = $('.js-new-protected-branch');
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.currentProjectUserDefaults = {};
this.buildDropdowns();
this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle');
this.bindEvents();
}
bindEvents() {
if (this.hasLicense) {
this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
}
this.$form.on('submit', this.onFormSubmit.bind(this));
}
onCodeOwnerToggleClick() {
this.$codeOwnerToggle.toggleClass('is-checked');
}
buildDropdowns() {
const $allowedToMergeDropdown = this.$form.find('.js-allowed-to-merge');
const $allowedToPushDropdown = this.$form.find('.js-allowed-to-push');
const $protectedBranchDropdown = this.$form.find('.js-protected-branch-select');
// Cache callback
this.onSelectCallback = this.onSelect.bind(this);
// Allowed to Merge dropdown
this.protectedBranchMergeAccessDropdown = new ProtectedBranchAccessDropdown({
this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({
$dropdown: $allowedToMergeDropdown,
data: gon.merge_access_levels,
accessLevelsData: gon.merge_access_levels,
onSelect: this.onSelectCallback,
accessLevel: ACCESS_LEVELS.MERGE,
hasLicense: this.hasLicense,
});
// Allowed to Push dropdown
this.protectedBranchPushAccessDropdown = new ProtectedBranchAccessDropdown({
this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({
$dropdown: $allowedToPushDropdown,
data: gon.push_access_levels,
accessLevelsData: gon.push_access_levels,
onSelect: this.onSelectCallback,
accessLevel: ACCESS_LEVELS.PUSH,
hasLicense: this.hasLicense,
});
this.createItemDropdown = new CreateItemDropdown({
$dropdown: $protectedBranchDropdown,
$dropdown: this.$form.find('.js-protected-branch-select'),
defaultToggleLabel: __('Protected Branch'),
fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback,
@ -43,26 +64,66 @@ export default class ProtectedBranchCreate {
});
}
// This will run after clicked callback
// Enable submit button after selecting an option
onSelect() {
// Enable submit button
const $branchInput = this.$form.find('input[name="protected_branch[name]"]');
const $allowedToMergeInput = this.$form.find(
'input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]',
);
const $allowedToPushInput = this.$form.find(
'input[name="protected_branch[push_access_levels_attributes][0][access_level]"]',
);
const completedForm = !(
$branchInput.val() &&
$allowedToMergeInput.length &&
$allowedToPushInput.length
const $allowedToMerge = this[`${ACCESS_LEVELS.MERGE}_dropdown`].getSelectedItems();
const $allowedToPush = this[`${ACCESS_LEVELS.PUSH}_dropdown`].getSelectedItems();
const toggle = !(
this.$form.find('input[name="protected_branch[name]"]').val() &&
$allowedToMerge.length &&
$allowedToPush.length
);
this.$form.find('input[type="submit"]').prop('disabled', completedForm);
this.$form.find('input[type="submit"]').attr('disabled', toggle);
}
static getProtectedBranches(term, callback) {
callback(gon.open_branches);
}
getFormData() {
const formData = {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
protected_branch: {
name: this.$form.find('input[name="protected_branch[name]"]').val(),
code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
},
};
Object.keys(ACCESS_LEVELS).forEach(level => {
const accessLevel = ACCESS_LEVELS[level];
const selectedItems = this[`${accessLevel}_dropdown`].getSelectedItems();
const levelAttributes = [];
selectedItems.forEach(item => {
if (item.type === LEVEL_TYPES.USER) {
levelAttributes.push({
user_id: item.user_id,
});
} else if (item.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({
access_level: item.access_level,
});
} else if (item.type === LEVEL_TYPES.GROUP) {
levelAttributes.push({
group_id: item.group_id,
});
}
});
formData.protected_branch[`${accessLevel}_attributes`] = levelAttributes;
});
return formData;
}
onFormSubmit(e) {
e.preventDefault();
axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData())
.then(() => {
window.location.reload();
})
.catch(() => Flash(__('Failed to protect the branch')));
}
}

View file

@ -1,78 +1,165 @@
import flash from '../flash';
import axios from '../lib/utils/axios_utils';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import { find } from 'lodash';
import AccessDropdown from '~/projects/settings/access_dropdown';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
import { __ } from '~/locale';
export default class ProtectedBranchEdit {
constructor(options) {
this.hasLicense = options.hasLicense;
this.$wraps = {};
this.hasChanges = false;
this.$wrap = options.$wrap;
this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
this.onSelectCallback = this.onSelect.bind(this);
this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle');
this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest(
`.${ACCESS_LEVELS.MERGE}-container`,
);
this.$wraps[ACCESS_LEVELS.PUSH] = this.$allowedToPushDropdown.closest(
`.${ACCESS_LEVELS.PUSH}-container`,
);
this.buildDropdowns();
this.bindEvents();
}
bindEvents() {
if (this.hasLicense) {
this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
}
}
onCodeOwnerToggleClick() {
this.$codeOwnerToggle.toggleClass('is-checked');
this.$codeOwnerToggle.prop('disabled', true);
const formData = {
code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
};
this.updateCodeOwnerApproval(formData);
}
updateCodeOwnerApproval(formData) {
axios
.patch(this.$wrap.data('url'), {
protected_branch: formData,
})
.then(() => {
this.$codeOwnerToggle.prop('disabled', false);
})
.catch(() => {
Flash(__('Failed to update branch!'));
});
}
buildDropdowns() {
// Allowed to merge dropdown
this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({
this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({
accessLevel: ACCESS_LEVELS.MERGE,
accessLevelsData: gon.merge_access_levels,
$dropdown: this.$allowedToMergeDropdown,
data: gon.merge_access_levels,
onSelect: this.onSelectCallback,
onSelect: this.onSelectOption.bind(this),
onHide: this.onDropdownHide.bind(this),
hasLicense: this.hasLicense,
});
// Allowed to push dropdown
this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({
this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({
accessLevel: ACCESS_LEVELS.PUSH,
accessLevelsData: gon.push_access_levels,
$dropdown: this.$allowedToPushDropdown,
data: gon.push_access_levels,
onSelect: this.onSelectCallback,
onSelect: this.onSelectOption.bind(this),
onHide: this.onDropdownHide.bind(this),
hasLicense: this.hasLicense,
});
}
onSelect() {
const $allowedToMergeInput = this.$wrap.find(
`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`,
);
const $allowedToPushInput = this.$wrap.find(
`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`,
);
onSelectOption() {
this.hasChanges = true;
}
// Do not update if one dropdown has not selected any option
if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
onDropdownHide() {
if (!this.hasChanges) {
return;
}
this.$allowedToMergeDropdown.disable();
this.$allowedToPushDropdown.disable();
this.hasChanges = true;
this.updatePermissions();
}
updatePermissions() {
const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => {
const accessLevelName = ACCESS_LEVELS[level];
const inputData = this[`${accessLevelName}_dropdown`].getInputData(accessLevelName);
acc[`${accessLevelName}_attributes`] = inputData;
return acc;
}, {});
axios
.patch(this.$wrap.data('url'), {
protected_branch: {
merge_access_levels_attributes: [
{
id: this.$allowedToMergeDropdown.data('accessLevelId'),
access_level: $allowedToMergeInput.val(),
},
],
push_access_levels_attributes: [
{
id: this.$allowedToPushDropdown.data('accessLevelId'),
access_level: $allowedToPushInput.val(),
},
],
},
protected_branch: formData,
})
.then(() => {
.then(({ data }) => {
this.hasChanges = false;
Object.keys(ACCESS_LEVELS).forEach(level => {
const accessLevelName = ACCESS_LEVELS[level];
// The data coming from server will be the new persisted *state* for each dropdown
this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`);
});
this.$allowedToMergeDropdown.enable();
this.$allowedToPushDropdown.enable();
})
.catch(() => {
this.$allowedToMergeDropdown.enable();
this.$allowedToPushDropdown.enable();
flash(
__('Failed to update branch!'),
'alert',
document.querySelector('.js-protected-branches-list'),
);
Flash(__('Failed to update branch!'));
});
}
setSelectedItemsToDropdown(items = [], dropdownName) {
const itemsToAdd = items.map(currentItem => {
if (currentItem.user_id) {
// Do this only for users for now
// get the current data for selected items
const selectedItems = this[dropdownName].getSelectedItems();
const currentSelectedItem = find(selectedItems, {
user_id: currentItem.user_id,
});
return {
id: currentItem.id,
user_id: currentItem.user_id,
type: LEVEL_TYPES.USER,
persisted: true,
name: currentSelectedItem.name,
username: currentSelectedItem.username,
avatar_url: currentSelectedItem.avatar_url,
};
} else if (currentItem.group_id) {
return {
id: currentItem.id,
group_id: currentItem.group_id,
type: LEVEL_TYPES.GROUP,
persisted: true,
};
}
return {
id: currentItem.id,
access_level: currentItem.access_level,
type: LEVEL_TYPES.ROLE,
persisted: true,
};
});
this[dropdownName].setSelectedItems(itemsToAdd);
}
}

View file

@ -13,6 +13,7 @@ export default class ProtectedBranchEditList {
this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => {
new ProtectedBranchEdit({
$wrap: $(el),
hasLicense: false,
});
});
}

View file

@ -1,3 +1,13 @@
<script>
export default {
props: {
mergeRequestsIllustrationPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<router-view />
<router-view :merge-requests-illustration-path="mergeRequestsIllustrationPath" />
</template>

View file

@ -1,79 +0,0 @@
<script>
import { isString } from 'lodash';
import { GlLink, GlButton } from '@gitlab/ui';
const validateUrlAndLabel = value => isString(value.label) && isString(value.url);
export default {
components: {
GlLink,
GlButton,
},
props: {
branch: {
type: Object,
required: true,
validator: validateUrlAndLabel,
},
commit: {
type: Object,
required: true,
validator: validateUrlAndLabel,
},
mergeRequest: {
type: Object,
required: true,
validator: validateUrlAndLabel,
},
returnUrl: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div>
<div class="border-bottom pb-4">
<h3>{{ s__('StaticSiteEditor|Success!') }}</h3>
<p>
{{
s__(
'StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes wont be visible on the site until the merge request has been accepted.',
)
}}
</p>
<div class="d-flex justify-content-end">
<gl-button v-if="returnUrl" ref="returnToSiteButton" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
}}</gl-button>
<gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success">
{{ s__('StaticSiteEditor|View merge request') }}
</gl-button>
</div>
</div>
<div class="pt-2">
<h4>{{ s__('StaticSiteEditor|Summary of changes') }}</h4>
<ul>
<li>
{{ s__('StaticSiteEditor|You created a new branch:') }}
<gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|You created a merge request:') }}
<gl-link ref="mergeRequestLink" :href="mergeRequest.url">{{
mergeRequest.label
}}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|You added a commit:') }}
<gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link>
</li>
</ul>
</div>
</div>
</template>

View file

@ -5,7 +5,14 @@ import createRouter from './router';
import createApolloProvider from './graphql';
const initStaticSiteEditor = el => {
const { isSupportedContent, path: sourcePath, baseUrl, namespace, project } = el.dataset;
const {
isSupportedContent,
path: sourcePath,
baseUrl,
namespace,
project,
mergeRequestsIllustrationPath,
} = el.dataset;
const { current_username: username } = window.gon;
const returnUrl = el.dataset.returnUrl || null;
@ -26,7 +33,11 @@ const initStaticSiteEditor = el => {
App,
},
render(createElement) {
return createElement('app');
return createElement('app', {
props: {
mergeRequestsIllustrationPath,
},
});
},
});
};

View file

@ -1,12 +1,21 @@
<script>
import { GlEmptyState, GlButton } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
import SavedChangesMessage from '../components/saved_changes_message.vue';
import { HOME_ROUTE } from '../router/constants';
export default {
components: {
SavedChangesMessage,
GlEmptyState,
GlButton,
},
props: {
mergeRequestsIllustrationPath: {
type: String,
required: true,
},
},
apollo: {
savedContentMeta: {
@ -16,20 +25,65 @@ export default {
query: appDataQuery,
},
},
computed: {
updatedFileDescription() {
const { sourcePath } = this.appData;
return sprintf(s__('Update %{sourcePath} file'), { sourcePath });
},
},
created() {
if (!this.savedContentMeta) {
this.$router.push(HOME_ROUTE);
}
},
title: s__('StaticSiteEditor|Your merge request has been created'),
primaryButtonText: __('View merge request'),
returnToSiteBtnText: s__('StaticSiteEditor|Return to site'),
mergeRequestInstructionsHeading: s__(
'StaticSiteEditor|To see your changes live you will need to do the following things:',
),
addTitleInstruction: s__('StaticSiteEditor|1. Add a clear title to describe the change.'),
addDescriptionInstruction: s__(
'StaticSiteEditor|2. Add a description to explain why the change is being made.',
),
assignMergeRequestInstruction: s__(
'StaticSiteEditor|3. Assign a person to review and accept the merge request.',
),
};
</script>
<template>
<div v-if="savedContentMeta" class="container">
<saved-changes-message
:branch="savedContentMeta.branch"
:commit="savedContentMeta.commit"
:merge-request="savedContentMeta.mergeRequest"
:return-url="appData.returnUrl"
/>
<div
v-if="savedContentMeta"
class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column"
>
<div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100">
<div class="container gl-py-4">
<gl-button
v-if="appData.returnUrl"
ref="returnToSiteButton"
class="gl-mr-5"
:href="appData.returnUrl"
>{{ $options.returnToSiteBtnText }}</gl-button
>
<strong>
{{ updatedFileDescription }}
</strong>
</div>
</div>
<gl-empty-state
class="gl-my-9"
:primary-button-text="$options.primaryButtonText"
:title="$options.title"
:primary-button-link="savedContentMeta.mergeRequest.url"
:svg-path="mergeRequestsIllustrationPath"
>
<template #description>
<p>{{ $options.mergeRequestInstructionsHeading }}</p>
<p>{{ $options.addTitleInstruction }}</p>
<p>{{ $options.addDescriptionInstruction }}</p>
<p>{{ $options.assignMergeRequestInstruction }}</p>
</template>
</gl-empty-state>
</div>
</template>

View file

@ -586,5 +586,16 @@ const fileNameIcons = {
};
export default function getIconForFile(name) {
return fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop() : ''] || '';
return (
fileNameIcons[name] ||
fileExtensionIcons[
name
? name
.split('.')
.pop()
.toLowerCase()
: ''
] ||
''
);
}

View file

@ -97,7 +97,7 @@
%td
.float-right
- if can?(current_user, :read_build, job) && job.artifacts?
= link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build' do
= link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build gl-button btn-icon btn-svg' do
= sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?

View file

@ -1,3 +1,5 @@
- add_page_startup_api_call discussions_path(@issue)
- @gfm_form = true
- content_for :note_actions do

View file

@ -9,6 +9,7 @@
- can_reopen_issue = can?(current_user, :reopen_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
- can_create_issue = show_new_issue_link?(@project)
- related_branches_path = related_branches_project_issue_path(@project, @issue)
= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
= render "projects/issues/alert_moved_from_service_desk", issue: @issue
@ -82,7 +83,8 @@
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
- if can?(current_user, :download_code, @project)
#related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
- add_page_startup_api_call related_branches_path
#related-branches{ data: { url: related_branches_path } }
-# This element is filled in using JavaScript.
.content-block.emoji-block.emoji-block-sticky

View file

@ -1 +1 @@
#static-site-editor{ data: @config.payload }
#static-site-editor{ data: @config.payload.merge({ merge_requests_illustration_path: image_path('illustrations/merge_requests.svg') }) }

View file

@ -0,0 +1,5 @@
---
title: Improve the IA and styling of the Success screen in the Static Site Editor
merge_request: 37475
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Fix misalignment of download icon on jobs page
merge_request: 37966
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Improve performance of Banzai reference filters
merge_request: 37465
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Make file icons extension detection be case-insensitive
merge_request: 37817
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Adds clarifying documentation on EKS IAM roles
merge_request: 37870
author:
type: added

View file

@ -13,6 +13,7 @@ Gitlab.ee do
Elasticsearch::Model::Adapter::Multiple::Records.prepend GemExtensions::Elasticsearch::Model::Adapter::Multiple::Records
Elasticsearch::Model::Indexing::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Indexing::InstanceMethods
Elasticsearch::Model::Adapter::ActiveRecord::Importing.prepend GemExtensions::Elasticsearch::Model::Adapter::ActiveRecord::Importing
Elasticsearch::Model::Adapter::ActiveRecord::Records.prepend GemExtensions::Elasticsearch::Model::Adapter::ActiveRecord::Records
Elasticsearch::Model::Client::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Client
Elasticsearch::Model::Client::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client
Elasticsearch::Model::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client

View file

@ -294,7 +294,7 @@ marked as Satisfied.
> - From GitLab 9.2, PDFs, images, videos, and other formats can be previewed directly in the job artifacts browser without the need to download them.
> - Introduced in [GitLab 10.1](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/14399), HTML files in a public project can be previewed directly in a new tab without the need to download them when [GitLab Pages](../../administration/pages/index.md) is enabled. The same applies for textual formats (currently supported extensions: `.txt`, `.json`, and `.log`).
> - Introduced in [GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16675), artifacts in private projects can be previewed when [GitLab Pages access control](../../administration/pages/index.md#access-control) is enabled.
> - Introduced in [GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16675), artifacts in internal and private projects can be previewed when [GitLab Pages access control](../../administration/pages/index.md#access-control) is enabled.
After a job finishes, if you visit the job's specific page, there are three
buttons. You can download the artifacts archive or browse its contents, whereas
@ -311,6 +311,8 @@ Below you can see what browsing looks like. In this case we have browsed inside
the archive and at this point there is one directory, a couple files, and
one HTML file that you can view directly online when
[GitLab Pages](../../administration/pages/index.md) is enabled (opens in a new tab).
Select artifacts in internal and private projects can only be previewed when
[GitLab Pages access control](../../administration/pages/index.md#access-control) is enabled.
![Job artifacts browser](img/job_artifacts_browser.png)

View file

@ -7,24 +7,6 @@ type: reference
# Getting started with GitLab CI/CD
NOTE: **Note:**
Starting from version 8.0, GitLab [Continuous Integration](https://about.gitlab.com/stages-devops-lifecycle/continuous-integration/) (CI)
is fully integrated into GitLab itself and is [enabled](../enable_or_disable_ci.md) by default on all
projects.
NOTE: **Note:**
Please keep in mind that only project Maintainers and Admin users have
the permissions to access a project's settings.
NOTE: **Note:**
Coming over to GitLab from Jenkins? Check out our [reference](../jenkins/index.md)
for converting your pre-existing pipelines over to our format.
NOTE: **Note:**
There are a few different [basic pipeline architectures](../pipelines/pipeline_architectures.md)
that you can consider for use in your project. You may want to familiarize
yourself with these prior to getting started.
GitLab offers a [continuous integration](https://about.gitlab.com/stages-devops-lifecycle/continuous-integration/) service. For each commit or push to trigger your CI
[pipeline](../pipelines/index.md), you must:
@ -49,7 +31,11 @@ something.
It's also common to use pipelines to automatically deploy
tested code to staging and production environments.
---
If you're already familiar with general CI/CD concepts, you can review which
[pipeline architectures](../pipelines/pipeline_architectures.md) can be used
in your projects. If you're coming over to GitLab from Jenkins, you can check out
our [reference](../migration/jenkins.md) for converting your pre-existing pipelines
over to our format.
This guide assumes that you have:

View file

@ -3,7 +3,7 @@
NOTE: **Note:**
This documentation focuses only on how to **configure** a Jenkins *integration* with
GitLab. Learn how to **migrate** from Jenkins to GitLab CI/CD in our
[Migrating from Jenkins](../ci/jenkins/index.md) documentation.
[Migrating from Jenkins](../ci/migration/jenkins.md) documentation.
From GitLab, you can trigger a Jenkins build when you push code to a repository, or when a merge
request is created. In return, Jenkins shows the pipeline status on merge requests widgets and

View file

@ -965,6 +965,7 @@ documentation:
- [Google GKE](https://docs.cilium.io/en/stable/gettingstarted/k8s-install-gke/#deploy-cilium)
- [AWS EKS](https://docs.cilium.io/en/stable/gettingstarted/k8s-install-eks/#deploy-cilium)
- [Azure AKS](https://docs.cilium.io/en/stable/gettingstarted/k8s-install-aks/#deploy-cilium)
You can customize Cilium's Helm variables by defining the
`.gitlab/managed-apps/cilium/values.yaml` file in your cluster

View file

@ -62,6 +62,11 @@ To create and add a new Kubernetes cluster to your project, group, or instance:
1. Click **Add Kubernetes cluster**.
1. Under the **Create new cluster** tab, click **Amazon EKS**. You will be provided with an
`Account ID` and `External ID` to use in the next step.
1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an EKS management IAM role.
To do so, follow the [Amazon EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) instructions
to create a IAM role suitable for managing the AWS EKS cluster's resources on your behalf.
In addition to the policies that guide suggests, you must also include the `AmazonEKSServicePolicy`
policy for this role in order for GitLab to manage the EKS cluster correctly.
1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an IAM role:
1. From the left panel, select **Roles**.
1. Click **Create role**.
@ -137,9 +142,15 @@ To create and add a new Kubernetes cluster to your project, group, or instance:
- **Kubernetes cluster name** - The name you wish to give the cluster.
- **Environment scope** - The [associated environment](index.md#setting-the-environment-scope-premium) to this cluster.
- **Kubernetes version** - The Kubernetes version to use. Currently the only version supported is 1.14.
- **Role name** - Select the [IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html)
to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. This IAM role is separate
to the IAM role created above, you will need to create it if it does not yet exist.
- **Role name** - Select the **EKS IAM role** you created earlier to allow Amazon EKS
and the Kubernetes control plane to manage AWS resources on your behalf.
NOTE: **Note:**
This IAM role is _not_ the IAM role you created in the previous step. It should be
the one you created much earlier by following the
[Amazon EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html)
guide.
- **Region** - The [region](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html)
in which the cluster will be created.
- **Key pair name** - Select the [key pair](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html)
@ -194,10 +205,10 @@ If the `Cluster` resource failed with the error
the role specified in **Role name** is not configured correctly.
NOTE: **Note:**
This role should not be the same as the one created above. If you don't have an
existing
[EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html),
you must create one.
This role should be the role you created by following the
[EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) guide.
In addition to the policies that guide suggests, you must also include the
`AmazonEKSServicePolicy` policy for this role in order for GitLab to manage the EKS cluster correctly.
## Existing EKS cluster

View file

@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
GitLab makes it easy to secure applications deployed in [connected Kubernetes clusters](index.md).
You can benefit from the protection of a [Web Application Firewall](../../../topics/web_application_firewall/quick_start_guide.md),
[Network Policies](../../../topics/autodevops/stages.md#network-policy),
or even [Container Host Security](../../clusters/applications.md#install-falco-using-gitlab-cicd).
and [Container Host Security](../../clusters/applications.md#install-falco-using-gitlab-cicd).
This page contains full end-to-end steps and instructions to connect your cluster to GitLab and
install these features, whether or not your applications are deployed through GitLab CI/CD. If you
@ -25,7 +25,7 @@ At a high level, the required steps include the following:
- Connect the cluster to GitLab.
- Set up one or more runners.
- Set up a cluster management project.
- Install a Web Application Firewall, Network Policies, and/or Container Host
- Install a Web Application Firewall, and/or Network Policies, and/or Container Host
Security.
- Install Prometheus to get statistics and metrics in the
[threat monitoring](../../application_security/threat_monitoring/)
@ -57,7 +57,7 @@ uses Sidekiq (a background processing service) to facilitate this.
```
Although this installation method is easier because it's a point-and-click action in the user
interface, it's inflexible and hard to debug. When something goes wrong, you can't see the
interface, it's inflexible and harder to debug. If something goes wrong, you can't see the
deployment logs. The Web Application Firewall feature uses this installation method.
However, the next generation of GitLab Managed Apps V2 ([CI/CD-based GitLab Managed Apps](https://gitlab.com/groups/gitlab-org/-/epics/2103))
@ -75,10 +75,10 @@ sequenceDiagram
```
Debugging is easier because you have access to the raw logs of these jobs (the Helm Tiller output is
available as an artifact in case of failure) and the flexibility is much better. Since these
available as an artifact in case of failure), and the flexibility is much better. Since these
deployments are only triggered when a pipeline is running (most likely when there's a new commit in
the cluster management repository), every action has a paper trail and follows the classic merge
request workflow (approvals, merge, deploy). The Network Policy (Cilium) Managed App and Container
request workflow (approvals, merge, deploy). The Network Policy (Cilium) Managed App, and Container
Host Security (Falco) are deployed with this model.
## Connect the cluster to GitLab

View file

@ -66,7 +66,7 @@ Your account has been blocked. Fatal: Could not read from remote repository
You can assure your users that they have not been [Blocked](admin_area/blocking_unblocking_users.md) by an administrator.
When affected users see this message, they must confirm their email address before they can commit code.
## What do I need to know as an administrator of a GitLab Self-Managed Instance?
## What do I need to know as an administrator of a GitLab self-managed Instance?
You have the following options to help your users:
@ -87,6 +87,19 @@ admin.confirmed_at = Time.zone.now
admin.save!
```
## How do I force-confirm all users on my self-managed instance?
If you are an administrator and would like to force-confirm all users on your system, sign in to your GitLab
instance with a [Rails console session](../administration/troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session).
Once connected, run the following commands to confirm all user accounts:
```ruby
User.where('LENGTH(confirmation_token) = 32').where(confirmed_at: nil).find_each { |u| u.confirmed_at = Time.now; u.save }
```
CAUTION: **Caution:**
The command described in this section may activate users who have not properly confirmed their email addresses.
## What about LDAP users?
LDAP users should NOT be affected.

View file

@ -25,14 +25,12 @@ module Banzai
def initialize(doc, context = nil, result = nil)
super
if update_nodes_enabled?
@new_nodes = {}
@nodes = self.result[:reference_filter_nodes]
end
@new_nodes = {}
@nodes = self.result[:reference_filter_nodes]
end
def call_and_update_nodes
update_nodes_enabled? ? with_update_nodes { call } : call
with_update_nodes { call }
end
# Returns a data attribute String to attach to a reference link
@ -165,11 +163,7 @@ module Banzai
end
def replace_text_with_html(node, index, html)
if update_nodes_enabled?
replace_and_update_new_nodes(node, index, html)
else
node.replace(html)
end
replace_and_update_new_nodes(node, index, html)
end
def replace_and_update_new_nodes(node, index, html)
@ -209,10 +203,6 @@ module Banzai
end
result[:reference_filter_nodes] = nodes
end
def update_nodes_enabled?
Feature.enabled?(:update_nodes_for_banzai_reference_filter, project)
end
end
end
end

View file

@ -10,7 +10,6 @@
# alt_usage_data { Gitlab::VERSION }
# redis_usage_data(Gitlab::UsageDataCounters::WikiPageCounter)
# redis_usage_data { ::Gitlab::UsageCounters::PodLogs.usage_totals[:total] }
module Gitlab
class UsageData
BATCH_SIZE = 100
@ -84,9 +83,11 @@ module Gitlab
auto_devops_enabled: count(::ProjectAutoDevops.enabled),
auto_devops_disabled: count(::ProjectAutoDevops.disabled),
deploy_keys: count(DeployKey),
# rubocop: disable UsageData/LargeTable:
deployments: deployment_count(Deployment),
successful_deployments: deployment_count(Deployment.success),
failed_deployments: deployment_count(Deployment.failed),
# rubocop: enable UsageData/LargeTable:
environments: count(::Environment),
clusters: count(::Clusters::Cluster),
clusters_enabled: count(::Clusters::Cluster.enabled),
@ -171,9 +172,11 @@ module Gitlab
def system_usage_data_monthly
{
counts_monthly: {
# rubocop: disable UsageData/LargeTable:
deployments: deployment_count(Deployment.where(last_28_days_time_period)),
successful_deployments: deployment_count(Deployment.success.where(last_28_days_time_period)),
failed_deployments: deployment_count(Deployment.failed.where(last_28_days_time_period)),
# rubocop: enable UsageData/LargeTable:
personal_snippets: count(PersonalSnippet.where(last_28_days_time_period)),
project_snippets: count(ProjectSnippet.where(last_28_days_time_period))
}.tap do |data|
@ -332,14 +335,18 @@ module Gitlab
finish = ::Project.maximum(:id)
results[:projects_with_expiration_policy_disabled] = distinct_count(::ContainerExpirationPolicy.where(enabled: false), :project_id, start: start, finish: finish)
# rubocop: disable UsageData/LargeTable
base = ::ContainerExpirationPolicy.active
# rubocop: enable UsageData/LargeTable
results[:projects_with_expiration_policy_enabled] = distinct_count(base, :project_id, start: start, finish: finish)
# rubocop: disable UsageData/LargeTable
%i[keep_n cadence older_than].each do |option|
::ContainerExpirationPolicy.public_send("#{option}_options").keys.each do |value| # rubocop: disable GitlabSecurity/PublicSend
results["projects_with_expiration_policy_enabled_with_#{option}_set_to_#{value}".to_sym] = distinct_count(base.where(option => value), :project_id, start: start, finish: finish)
end
end
# rubocop: enable UsageData/LargeTable
results[:projects_with_expiration_policy_enabled_with_keep_n_unset] = distinct_count(base.where(keep_n: nil), :project_id, start: start, finish: finish)
results[:projects_with_expiration_policy_enabled_with_older_than_unset] = distinct_count(base.where(older_than: nil), :project_id, start: start, finish: finish)
@ -350,9 +357,11 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def services_usage
# rubocop: disable UsageData/LargeTable:
Service.available_services_names.without('jira').each_with_object({}) do |service_name, response|
response["projects_#{service_name}_active".to_sym] = count(Service.active.where(template: false, type: "#{service_name}_service".camelize))
end.merge(jira_usage).merge(jira_import_usage)
# rubocop: enable UsageData/LargeTable:
end
def jira_usage
@ -365,6 +374,7 @@ module Gitlab
projects_jira_active: 0
}
# rubocop: disable UsageData/LargeTable:
JiraService.active.includes(:jira_tracker_data).find_in_batches(batch_size: BATCH_SIZE) do |services|
counts = services.group_by do |service|
# TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
@ -376,21 +386,24 @@ module Gitlab
results[:projects_jira_cloud_active] += counts[:cloud].size if counts[:cloud]
results[:projects_jira_active] += services.size
end
# rubocop: enable UsageData/LargeTable:
results
rescue ActiveRecord::StatementInvalid
{ projects_jira_server_active: FALLBACK, projects_jira_cloud_active: FALLBACK, projects_jira_active: FALLBACK }
end
# rubocop: disable UsageData/LargeTable
def successful_deployments_with_cluster(scope)
scope
.joins(cluster: :deployments)
.merge(Clusters::Cluster.enabled)
.merge(Deployment.success)
end
# rubocop: enable UsageData/LargeTable
# rubocop: enable CodeReuse/ActiveRecord
def jira_import_usage
# rubocop: disable UsageData/LargeTable
finished_jira_imports = JiraImportState.finished
{
@ -398,6 +411,7 @@ module Gitlab
jira_imports_projects_count: distinct_count(finished_jira_imports, :project_id),
jira_imports_total_imported_issues_count: alt_usage_data { JiraImportState.finished_imports_count }
}
# rubocop: enable UsageData/LargeTable
end
def user_preferences_usage
@ -406,13 +420,8 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def merge_requests_users(time_period)
query =
Event
.where(target_type: Event::TARGET_TYPES[:merge_request].to_s)
.where(time_period)
distinct_count(
query,
Event.where(target_type: Event::TARGET_TYPES[:merge_request].to_s).where(time_period),
:author_id,
start: user_minimum_id,
finish: user_maximum_id
@ -450,6 +459,7 @@ module Gitlab
end
# rubocop: disable CodeReuse/ActiveRecord
# rubocop: disable UsageData/LargeTable
def usage_activity_by_stage_configure(time_period)
{
clusters_applications_cert_managers: cluster_applications_user_distinct_count(::Clusters::Applications::CertManager, time_period),
@ -470,6 +480,7 @@ module Gitlab
project_clusters_enabled: clusters_user_distinct_count(::Clusters::Cluster.enabled.project_type, time_period)
}
end
# rubocop: enable UsageData/LargeTable
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
@ -628,8 +639,9 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def service_desk_counts
# rubocop: disable UsageData/LargeTable:
projects_with_service_desk = ::Project.where(service_desk_enabled: true)
# rubocop: enable UsageData/LargeTable:
{
service_desk_enabled_projects: count(projects_with_service_desk),
service_desk_issues: count(

View file

@ -22720,6 +22720,15 @@ msgstr ""
msgid "Static Application Security Testing (SAST)"
msgstr ""
msgid "StaticSiteEditor|1. Add a clear title to describe the change."
msgstr ""
msgid "StaticSiteEditor|2. Add a description to explain why the change is being made."
msgstr ""
msgid "StaticSiteEditor|3. Assign a person to review and accept the merge request."
msgstr ""
msgid "StaticSiteEditor|An error occurred while submitting your changes."
msgstr ""
@ -22741,34 +22750,19 @@ msgstr ""
msgid "StaticSiteEditor|Static site editor"
msgstr ""
msgid "StaticSiteEditor|Success!"
msgstr ""
msgid "StaticSiteEditor|Summary of changes"
msgstr ""
msgid "StaticSiteEditor|The Static Site Editor is currently configured to only edit Markdown content on pages generated from Middleman. Visit the documentation to learn more about configuring your site to use the Static Site Editor."
msgstr ""
msgid "StaticSiteEditor|To see your changes live you will need to do the following things:"
msgstr ""
msgid "StaticSiteEditor|Update %{sourcePath} file"
msgstr ""
msgid "StaticSiteEditor|View documentation"
msgstr ""
msgid "StaticSiteEditor|View merge request"
msgstr ""
msgid "StaticSiteEditor|You added a commit:"
msgstr ""
msgid "StaticSiteEditor|You created a merge request:"
msgstr ""
msgid "StaticSiteEditor|You created a new branch:"
msgstr ""
msgid "StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes wont be visible on the site until the merge request has been accepted."
msgid "StaticSiteEditor|Your merge request has been created"
msgstr ""
msgid "Statistics"
@ -25689,6 +25683,9 @@ msgstr ""
msgid "Update"
msgstr ""
msgid "Update %{sourcePath} file"
msgstr ""
msgid "Update all"
msgstr ""
@ -26425,6 +26422,9 @@ msgstr ""
msgid "View log"
msgstr ""
msgid "View merge request"
msgstr ""
msgid "View open merge request"
msgstr ""

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
module RuboCop
module Cop
module UsageData
class LargeTable < RuboCop::Cop::Cop
# This cop checks that batch count and distinct_count are used in usage_data.rb files in metrics based on ActiveRecord models.
#
# @example
#
# # bad
# Issue.count
# List.assignee.count
# ::Ci::Pipeline.auto_devops_source.count
# ZoomMeeting.distinct.count(:issue_id)
#
# # Good
# count(Issue)
# count(List.assignee)
# count(::Ci::Pipeline.auto_devops_source)
# distinct_count(ZoomMeeting, :issue_id)
MSG = 'Use one of the %{count_methods} methods for counting on %{class_name}'
# Match one level const as Issue, Gitlab
def_node_matcher :one_level_node, <<~PATTERN
(send
(const {nil? cbase} $...)
$...)
PATTERN
# Match two level const as ::Clusters::Cluster, ::Ci::Pipeline
def_node_matcher :two_level_node, <<~PATTERN
(send
(const
(const {nil? cbase} $...)
$...)
$...)
PATTERN
def on_send(node)
one_level_matches = one_level_node(node)
two_level_matches = two_level_node(node)
return unless Array(one_level_matches).any? || Array(two_level_matches).any?
if one_level_matches
class_name = one_level_matches[0].first
method_used = one_level_matches[1]&.first
else
class_name = "#{two_level_matches[0].first}::#{two_level_matches[1].first}".to_sym
method_used = two_level_matches[2]&.first
end
return if non_related?(class_name) || allowed_methods.include?(method_used)
counters_used = node.ancestors.any? { |ancestor| allowed_method?(ancestor) }
unless counters_used
add_offense(node, location: :expression, message: format(MSG, count_methods: count_methods.join(', '), class_name: class_name))
end
end
private
def count_methods
cop_config['CountMethods'] || []
end
def allowed_methods
cop_config['AllowedMethods'] || []
end
def non_related_classes
cop_config['NonRelatedClasses'] || []
end
def non_related?(class_name)
non_related_classes.include?(class_name)
end
def allowed_method?(ancestor)
ancestor.send_type? && !ancestor.dot? && count_methods.include?(ancestor.method_name)
end
end
end
end
end

View file

@ -0,0 +1,32 @@
UsageData/LargeTable:
Enabled: true
Include:
- 'lib/gitlab/usage_data.rb'
- 'ee/lib/ee/gitlab/usage_data.rb'
NonRelatedClasses:
- :Feature
- :Gitlab
- :Gitlab::AppLogger
- :Gitlab::Auth
- :Gitlab::CurrentSettings
- :Gitlab::Database
- :Gitlab::ErrorTracking
- :Gitlab::Geo
- :Gitlab::Git
- :Gitlab::IncomingEmail
- :Gitlab::Metrics
- :Gitlab::Runtime
- :Gitaly::Server
- :Gitlab::UsageData
- :License
- :Rails
- :Time
- :SECURE_PRODUCT_TYPES
- :Settings
CountMethods:
- :count
- :distinct_count
AllowedMethods:
- :arel_table
- :minimum
- :maximum

View file

@ -0,0 +1,140 @@
import $ from 'jquery';
import '~/gl_dropdown';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { LEVEL_TYPES } from '~/projects/settings/constants';
describe('AccessDropdown', () => {
const defaultLabel = 'dummy default label';
let dropdown;
beforeEach(() => {
setFixtures(`
<div id="dummy-dropdown">
<span class="dropdown-toggle-text"></span>
</div>
`);
const $dropdown = $('#dummy-dropdown');
$dropdown.data('defaultLabel', defaultLabel);
const options = {
$dropdown,
accessLevelsData: {
roles: [
{
id: 42,
text: 'Dummy Role',
},
],
},
};
dropdown = new AccessDropdown(options);
});
describe('toggleLabel', () => {
let $dropdownToggleText;
const dummyItems = [
{ type: LEVEL_TYPES.ROLE, access_level: 42 },
{ type: LEVEL_TYPES.USER },
{ type: LEVEL_TYPES.USER },
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
];
beforeEach(() => {
$dropdownToggleText = $('.dropdown-toggle-text');
});
it('displays number of items', () => {
dropdown.setSelectedItems(dummyItems);
$dropdownToggleText.addClass('is-default');
const label = dropdown.toggleLabel();
expect(label).toBe('1 role, 2 users, 3 groups');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
describe('without selected items', () => {
beforeEach(() => {
dropdown.setSelectedItems([]);
});
it('falls back to default label', () => {
const label = dropdown.toggleLabel();
expect(label).toBe(defaultLabel);
expect($dropdownToggleText).toHaveClass('is-default');
});
});
describe('with only role', () => {
beforeEach(() => {
dropdown.setSelectedItems(dummyItems.filter(item => item.type === LEVEL_TYPES.ROLE));
$dropdownToggleText.addClass('is-default');
});
it('displays the role name', () => {
const label = dropdown.toggleLabel();
expect(label).toBe('Dummy Role');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
describe('with only users', () => {
beforeEach(() => {
dropdown.setSelectedItems(dummyItems.filter(item => item.type === LEVEL_TYPES.USER));
$dropdownToggleText.addClass('is-default');
});
it('displays number of users', () => {
const label = dropdown.toggleLabel();
expect(label).toBe('2 users');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
describe('with only groups', () => {
beforeEach(() => {
dropdown.setSelectedItems(dummyItems.filter(item => item.type === LEVEL_TYPES.GROUP));
$dropdownToggleText.addClass('is-default');
});
it('displays number of groups', () => {
const label = dropdown.toggleLabel();
expect(label).toBe('3 groups');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
describe('with users and groups', () => {
beforeEach(() => {
const selectedTypes = [LEVEL_TYPES.GROUP, LEVEL_TYPES.USER];
dropdown.setSelectedItems(dummyItems.filter(item => selectedTypes.includes(item.type)));
$dropdownToggleText.addClass('is-default');
});
it('displays number of groups', () => {
const label = dropdown.toggleLabel();
expect(label).toBe('2 users, 3 groups');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
});
describe('userRowHtml', () => {
it('escapes users name', () => {
const user = {
avatar_url: '',
name: '<img src=x onerror=alert(document.domain)>',
username: 'test',
};
const template = dropdown.userRowHtml(user);
expect(template).not.toContain(user.name);
});
});
});

View file

@ -0,0 +1,34 @@
import { shallowMount } from '@vue/test-utils';
import App from '~/static_site_editor/components/app.vue';
describe('static_site_editor/components/app', () => {
const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg';
const RouterView = {
template: '<div></div>',
};
let wrapper;
const buildWrapper = () => {
wrapper = shallowMount(App, {
stubs: {
RouterView,
},
propsData: {
mergeRequestsIllustrationPath,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('passes merge request illustration path to the router view component', () => {
buildWrapper();
expect(wrapper.find(RouterView).attributes()).toMatchObject({
'merge-requests-illustration-path': mergeRequestsIllustrationPath,
});
});
});

View file

@ -1,56 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue';
import { returnUrl, savedContentMeta } from '../mock_data';
describe('~/static_site_editor/components/saved_changes_message.vue', () => {
let wrapper;
const { branch, commit, mergeRequest } = savedContentMeta;
const props = {
branch,
commit,
mergeRequest,
returnUrl,
};
const findReturnToSiteButton = () => wrapper.find({ ref: 'returnToSiteButton' });
const findMergeRequestButton = () => wrapper.find({ ref: 'mergeRequestButton' });
const findBranchLink = () => wrapper.find({ ref: 'branchLink' });
const findCommitLink = () => wrapper.find({ ref: 'commitLink' });
const findMergeRequestLink = () => wrapper.find({ ref: 'mergeRequestLink' });
beforeEach(() => {
wrapper = shallowMount(SavedChangesMessage, {
propsData: props,
});
});
afterEach(() => {
wrapper.destroy();
});
it.each`
text | findEl | url
${'Return to site'} | ${findReturnToSiteButton} | ${props.returnUrl}
${'View merge request'} | ${findMergeRequestButton} | ${props.mergeRequest.url}
`('renders "$text" button link', ({ text, findEl, url }) => {
const btn = findEl();
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe(text);
expect(btn.attributes('href')).toBe(url);
});
it.each`
desc | findEl | prop
${'branch'} | ${findBranchLink} | ${props.branch}
${'commit'} | ${findCommitLink} | ${props.commit}
${'merge request'} | ${findMergeRequestLink} | ${props.mergeRequest}
`('renders $desc link', ({ findEl, prop }) => {
const el = findEl();
expect(el.exists()).toBe(true);
expect(el.text()).toBe(prop.label);
expect(el.attributes('href')).toBe(prop.url);
});
});

View file

@ -1,17 +1,12 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlButton } from '@gitlab/ui';
import Success from '~/static_site_editor/pages/success.vue';
import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue';
import { savedContentMeta, returnUrl } from '../mock_data';
import { savedContentMeta, returnUrl, sourcePath } from '../mock_data';
import { HOME_ROUTE } from '~/static_site_editor/router/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('static_site_editor/pages/success', () => {
const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg';
let wrapper;
let store;
let router;
const buildRouter = () => {
@ -22,16 +17,22 @@ describe('static_site_editor/pages/success', () => {
const buildWrapper = (data = {}) => {
wrapper = shallowMount(Success, {
localVue,
store,
mocks: {
$router: router,
},
stubs: {
GlEmptyState,
GlButton,
},
propsData: {
mergeRequestsIllustrationPath,
},
data() {
return {
savedContentMeta,
appData: {
returnUrl,
sourcePath,
},
...data,
};
@ -39,7 +40,8 @@ describe('static_site_editor/pages/success', () => {
});
};
const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findReturnUrlButton = () => wrapper.find(GlButton);
beforeEach(() => {
buildRouter();
@ -50,29 +52,50 @@ describe('static_site_editor/pages/success', () => {
wrapper = null;
});
it('renders saved changes message', () => {
it('renders empty state with a link to the created merge request', () => {
buildWrapper();
expect(findSavedChangesMessage().exists()).toBe(true);
expect(findEmptyState().exists()).toBe(true);
expect(findEmptyState().props()).toMatchObject({
primaryButtonText: 'View merge request',
primaryButtonLink: savedContentMeta.mergeRequest.url,
title: 'Your merge request has been created',
svgPath: mergeRequestsIllustrationPath,
});
});
it('passes returnUrl to the saved changes message', () => {
it('displays merge request instructions in the empty state', () => {
buildWrapper();
expect(findSavedChangesMessage().props('returnUrl')).toBe(returnUrl);
expect(findEmptyState().text()).toContain(
'To see your changes live you will need to do the following things:',
);
expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.');
expect(findEmptyState().text()).toContain(
'2. Add a description to explain why the change is being made.',
);
expect(findEmptyState().text()).toContain(
'3. Assign a person to review and accept the merge request.',
);
});
it('passes saved content metadata to the saved changes message', () => {
it('displays return to site button', () => {
buildWrapper();
expect(findSavedChangesMessage().props('branch')).toBe(savedContentMeta.branch);
expect(findSavedChangesMessage().props('commit')).toBe(savedContentMeta.commit);
expect(findSavedChangesMessage().props('mergeRequest')).toBe(savedContentMeta.mergeRequest);
expect(findReturnUrlButton().text()).toBe('Return to site');
expect(findReturnUrlButton().attributes().href).toBe(returnUrl);
});
it('displays source path', () => {
buildWrapper();
expect(wrapper.text()).toContain(`Update ${sourcePath} file`);
});
it('redirects to the HOME route when content has not been submitted', () => {
buildWrapper({ savedContentMeta: null });
expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
expect(wrapper.html()).toBe('');
});
});

View file

@ -36,6 +36,9 @@ describe('File Icon component', () => {
fileName | iconName
${'test.js'} | ${'javascript'}
${'test.png'} | ${'image'}
${'test.PNG'} | ${'image'}
${'.npmrc'} | ${'npm'}
${'.Npmrc'} | ${'file'}
${'webpack.js'} | ${'webpack'}
`('should render a $iconName icon based on file ending', ({ fileName, iconName }) => {
createComponent({ fileName });

View file

@ -110,20 +110,6 @@ RSpec.describe Banzai::Filter::ReferenceFilter do
expect(filter.instance_variable_get(:@new_nodes)).to eq({ index => [filter.each_node.to_a[index]] })
end
context "with update_nodes_for_banzai_reference_filter feature flag disabled" do
before do
stub_feature_flags(update_nodes_for_banzai_reference_filter: false)
end
it 'does not call replace_and_update_new_nodes' do
expect(filter).not_to receive(:replace_and_update_new_nodes).with(filter.nodes[index], index, html)
filter.send(method_name, *args) do
html
end
end
end
end
end
@ -198,49 +184,20 @@ RSpec.describe Banzai::Filter::ReferenceFilter do
end
describe "#call_and_update_nodes" do
context "with update_nodes_for_banzai_reference_filter feature flag enabled" do
include_context 'new nodes'
let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
let(:filter) { described_class.new(document, project: project) }
include_context 'new nodes'
let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
let(:filter) { described_class.new(document, project: project) }
before do
stub_feature_flags(update_nodes_for_banzai_reference_filter: true)
end
it "updates all new nodes", :aggregate_failures do
filter.instance_variable_set('@nodes', nodes)
it "updates all new nodes", :aggregate_failures do
filter.instance_variable_set('@nodes', nodes)
expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
expect(filter).to receive(:with_update_nodes).and_call_original
expect(filter).to receive(:update_nodes!).and_call_original
expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
expect(filter).to receive(:with_update_nodes).and_call_original
expect(filter).to receive(:update_nodes!).and_call_original
filter.call_and_update_nodes
filter.call_and_update_nodes
expect(filter.result[:reference_filter_nodes]).to eq(expected_nodes)
end
end
context "with update_nodes_for_banzai_reference_filter feature flag disabled" do
include_context 'new nodes'
before do
stub_feature_flags(update_nodes_for_banzai_reference_filter: false)
end
it "does not change nodes", :aggregate_failures do
document = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
filter = described_class.new(document, project: project)
filter.instance_variable_set('@nodes', nodes)
expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
expect(filter).not_to receive(:with_update_nodes)
expect(filter).not_to receive(:update_nodes!)
filter.call_and_update_nodes
expect(filter.nodes).to eq(nodes)
expect(filter.result[:reference_filter_nodes]).to be nil
end
expect(filter.result[:reference_filter_nodes]).to eq(expected_nodes)
end
end
@ -251,10 +208,6 @@ RSpec.describe Banzai::Filter::ReferenceFilter do
let(:result) { { reference_filter_nodes: nodes } }
before do
stub_feature_flags(update_nodes_for_banzai_reference_filter: true)
end
it "updates all nodes", :aggregate_failures do
expect_next_instance_of(described_class) do |filter|
expect(filter).to receive(:call_and_update_nodes).and_call_original
@ -267,26 +220,5 @@ RSpec.describe Banzai::Filter::ReferenceFilter do
expect(result[:reference_filter_nodes]).to eq(expected_nodes)
end
context "with update_nodes_for_banzai_reference_filter feature flag disabled" do
let(:result) { {} }
before do
stub_feature_flags(update_nodes_for_banzai_reference_filter: false)
end
it "updates all nodes", :aggregate_failures do
expect_next_instance_of(described_class) do |filter|
expect(filter).to receive(:call_and_update_nodes).and_call_original
expect(filter).not_to receive(:with_update_nodes)
expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
expect(filter).not_to receive(:update_nodes!)
end
described_class.call(document, { project: project }, result)
expect(result[:reference_filter_nodes]).to be nil
end
end
end
end

View file

@ -30,34 +30,6 @@ RSpec.describe Banzai::Pipeline::GfmPipeline do
described_class.call(markdown, project: project)
end
context "with update_nodes_for_banzai_reference_filter feature flag disabled" do
before do
stub_feature_flags(update_nodes_for_banzai_reference_filter: false)
end
context 'when shorthand pattern #ISSUE_ID is used' do
it 'links an internal issues and doesnt store nodes in result[:reference_filter_nodes]', :aggregate_failures do
issue = create(:issue, project: project)
markdown = "text #{issue.to_reference(project, full: true)}"
result = described_class.call(markdown, project: project)
link = result[:output].css('a').first
expect(link['href']).to eq(Gitlab::Routing.url_helpers.project_issue_path(project, issue))
expect(result[:reference_filter_nodes]).to eq nil
end
end
it 'execute :each_node for each reference_filter', :aggregate_failures do
issue = create(:issue, project: project)
markdown = "text #{issue.to_reference(project, full: true)}"
described_class.reference_filters do |reference_filter|
expect_any_instance_of(reference_filter).to receive(:each_node).once
end
described_class.call(markdown, project: project)
end
end
context 'when shorthand pattern #ISSUE_ID is used' do
it 'links an internal issue if it exists' do
issue = create(:issue, project: project)

View file

@ -1,22 +1,32 @@
# frozen_string_literal: true
require 'spec_helper'
require "spec_helper"
RSpec.describe DesignManagement::DesignPolicy do
include DesignManagementTestHelpers
include_context 'ProjectPolicy context'
let(:guest_design_abilities) { %i[read_design] }
let(:developer_design_abilities) do
%i[create_design destroy_design]
end
let(:developer_design_abilities) { %i[create_design destroy_design] }
let(:design_abilities) { guest_design_abilities + developer_design_abilities }
let(:issue) { create(:issue, project: project) }
let_it_be(:guest) { create(:user) }
let_it_be(:reporter) { create(:user) }
let_it_be(:developer) { create(:user) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:owner) { create(:user) }
let_it_be(:admin) { create(:admin) }
let_it_be(:project) { create(:project, :public, namespace: owner.namespace) }
let_it_be(:issue) { create(:issue, project: project) }
let(:design) { create(:design, issue: issue) }
subject(:design_policy) { described_class.new(current_user, design) }
before_all do
project.add_guest(guest)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
end
shared_examples_for "design abilities not available" do
context "for owners" do
let(:current_user) { owner }
@ -71,11 +81,11 @@ RSpec.describe DesignManagement::DesignPolicy do
context "for admins" do
let(:current_user) { admin }
context 'when admin mode enabled', :enable_admin_mode do
context "when admin mode enabled", :enable_admin_mode do
it { is_expected.to be_allowed(*design_abilities) }
end
context 'when admin mode disabled' do
context "when admin mode disabled" do
it { is_expected.to be_allowed(*guest_design_abilities) }
it { is_expected.to be_disallowed(*developer_design_abilities) }
end
@ -122,7 +132,7 @@ RSpec.describe DesignManagement::DesignPolicy do
it_behaves_like "design abilities available for members"
context "for guests in private projects" do
let(:project) { create(:project, :private) }
let_it_be(:project) { create(:project, :private) }
let(:current_user) { guest }
it { is_expected.to be_allowed(*guest_design_abilities) }
@ -137,7 +147,7 @@ RSpec.describe DesignManagement::DesignPolicy do
end
context "when the issue is confidential" do
let(:issue) { create(:issue, :confidential, project: project) }
let_it_be(:issue) { create(:issue, :confidential, project: project) }
it_behaves_like "design abilities available for members"
@ -155,26 +165,24 @@ RSpec.describe DesignManagement::DesignPolicy do
end
context "when the issue is locked" do
let_it_be(:issue) { create(:issue, :locked, project: project) }
let(:current_user) { owner }
let(:issue) { create(:issue, :locked, project: project) }
it_behaves_like "read-only design abilities"
end
context "when the issue has moved" do
let_it_be(:issue) { create(:issue, project: project, moved_to: create(:issue)) }
let(:current_user) { owner }
let(:issue) { create(:issue, project: project, moved_to: create(:issue)) }
it_behaves_like "read-only design abilities"
end
context "when the project is archived" do
let_it_be(:project) { create(:project, :public, :archived) }
let_it_be(:issue) { create(:issue, project: project) }
let(:current_user) { owner }
before do
project.update!(archived: true)
end
it_behaves_like "read-only design abilities"
end
end

View file

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../../rubocop/cop/usage_data/large_table'
RSpec.describe RuboCop::Cop::UsageData::LargeTable, type: :rubocop do
include CopHelper
let(:large_tables) { %i[Rails Time] }
let(:count_methods) { %i[count distinct_count] }
let(:allowed_methods) { %i[minimum maximum] }
let(:config) do
RuboCop::Config.new('UsageData/LargeTable' => {
'NonRelatedClasses' => large_tables,
'CountMethods' => count_methods,
'AllowedMethods' => allowed_methods
})
end
subject(:cop) { described_class.new(config) }
context 'when in usage_data files' do
before do
allow(cop).to receive(:usage_data_files?).and_return(true)
end
context 'with large tables' do
context 'when calling Issue.count' do
it 'register an offence' do
inspect_source('Issue.count')
expect(cop.offenses.size).to eq(1)
end
end
context 'when calling Issue.active.count' do
it 'register an offence' do
inspect_source('Issue.active.count')
expect(cop.offenses.size).to eq(1)
end
end
context 'when calling count(Issue)' do
it 'does not register an offence' do
inspect_source('count(Issue)')
expect(cop.offenses).to be_empty
end
end
context 'when calling count(Ci::Build.active)' do
it 'does not register an offence' do
inspect_source('count(Ci::Build.active)')
expect(cop.offenses).to be_empty
end
end
context 'when calling Ci::Build.active.count' do
it 'register an offence' do
inspect_source('Ci::Build.active.count')
expect(cop.offenses.size).to eq(1)
end
end
context 'when using allowed methods' do
it 'does not register an offence' do
inspect_source('Issue.minimum')
expect(cop.offenses).to be_empty
end
end
end
context 'with non related class' do
it 'does not register an offence' do
inspect_source('Rails.count')
expect(cop.offenses).to be_empty
end
end
end
end

View file

@ -27,4 +27,9 @@ module ProtectedBranchHelpers
set_allowed_to('merge')
set_allowed_to('push')
end
def click_on_protect
click_on "Protect"
wait_for_requests
end
end

View file

@ -22,7 +22,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
end
end
click_on "Protect"
click_on_protect
expect(ProtectedBranch.count).to eq(1)
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id])
@ -45,7 +45,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
find(:link, 'No one').click
end
click_on "Protect"
click_on_protect
expect(ProtectedBranch.count).to eq(1)
@ -85,7 +85,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
find(:link, 'No one').click
end
click_on "Protect"
click_on_protect
expect(ProtectedBranch.count).to eq(1)
expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id])
@ -108,7 +108,7 @@ RSpec.shared_examples "protected branches > access control > CE" do
find(:link, 'No one').click
end
click_on "Protect"
click_on_protect
expect(ProtectedBranch.count).to eq(1)