From 03fbe61813666e96cd9b2dec9a22ab39f358f542 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 27 Jul 2020 18:09:54 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .rubocop.yml | 1 + .../projects/settings/repository/form.js | 2 +- .../projects/settings/access_dropdown.js | 524 ++++++++++++++++++ .../projects/settings/constants.js | 13 + .../protected_branches/constants.js | 18 + .../protected_branch_access_dropdown.js | 28 - .../protected_branch_create.js | 109 +++- .../protected_branch_edit.js | 171 ++++-- .../protected_branch_edit_list.js | 1 + .../static_site_editor/components/app.vue | 12 +- .../components/saved_changes_message.vue | 79 --- .../javascripts/static_site_editor/index.js | 15 +- .../static_site_editor/pages/success.vue | 72 ++- .../components/file_icon/file_icon_map.js | 13 +- app/views/projects/ci/builds/_build.html.haml | 2 +- .../projects/issues/_discussion.html.haml | 2 + app/views/projects/issues/show.html.haml | 4 +- .../static_site_editor/show.html.haml | 2 +- .../216868-improve-success-screen.yml | 5 + .../unreleased/225888-fix-download-icon.yml | 5 + ...move-feature-flag-for-reference-filter.yml | 5 + ...-file-icons-case-insensitive-extension.yml | 5 + .../unreleased/doc-iam-role-ambiguity.yml | 5 + config/initializers/elastic_client_setup.rb | 1 + doc/ci/pipelines/job_artifacts.md | 4 +- doc/ci/quick_start/README.md | 24 +- doc/integration/jenkins.md | 2 +- doc/user/clusters/applications.md | 1 + doc/user/project/clusters/add_eks_clusters.md | 25 +- doc/user/project/clusters/securing.md | 10 +- doc/user/upgrade_email_bypass.md | 15 +- lib/banzai/filter/reference_filter.rb | 18 +- lib/gitlab/usage_data.rb | 30 +- locale/gitlab.pot | 38 +- rubocop/cop/usage_data/large_table.rb | 87 +++ rubocop/rubocop-usage-data.yml | 32 ++ .../projects/settings/access_dropdown_spec.js | 140 +++++ .../static_site_editor/components/app_spec.js | 34 ++ .../components/saved_changes_message_spec.js | 56 -- .../static_site_editor/pages/success_spec.js | 63 ++- .../vue_shared/components/file_icon_spec.js | 3 + .../banzai/filter/reference_filter_spec.rb | 88 +-- spec/lib/banzai/pipeline/gfm_pipeline_spec.rb | 28 - .../design_management/design_policy_spec.rb | 42 +- .../cop/usage_data/large_table_spec.rb | 90 +++ spec/support/protected_branch_helpers.rb | 5 + ...nches_access_control_ce_shared_examples.rb | 8 +- 47 files changed, 1468 insertions(+), 469 deletions(-) create mode 100644 app/assets/javascripts/projects/settings/access_dropdown.js create mode 100644 app/assets/javascripts/projects/settings/constants.js create mode 100644 app/assets/javascripts/protected_branches/constants.js delete mode 100644 app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js delete mode 100644 app/assets/javascripts/static_site_editor/components/saved_changes_message.vue create mode 100644 changelogs/unreleased/216868-improve-success-screen.yml create mode 100644 changelogs/unreleased/225888-fix-download-icon.yml create mode 100644 changelogs/unreleased/230689-remove-feature-flag-for-reference-filter.yml create mode 100644 changelogs/unreleased/231488-file-icons-case-insensitive-extension.yml create mode 100644 changelogs/unreleased/doc-iam-role-ambiguity.yml create mode 100644 rubocop/cop/usage_data/large_table.rb create mode 100644 rubocop/rubocop-usage-data.yml create mode 100644 spec/frontend/projects/settings/access_dropdown_spec.js create mode 100644 spec/frontend/static_site_editor/components/app_spec.js delete mode 100644 spec/frontend/static_site_editor/components/saved_changes_message_spec.js create mode 100644 spec/rubocop/cop/usage_data/large_table_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 5f9fcbd9ec9..c48ea41ac33 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,6 +9,7 @@ require: inherit_from: - .rubocop_todo.yml - ./rubocop/rubocop-migrations.yml + - ./rubocop/rubocop-usage-data.yml inherit_mode: merge: diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index 3e02893f24c..eff45bad603 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -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'); diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js new file mode 100644 index 00000000000..b9d75340f8b --- /dev/null +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -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 ` +
  • + + + ${escape(user.name)} + ${user.username} + +
  • + `; + } + + groupRowHtml(group, isActive) { + const isActiveClass = isActive || ''; + const avatarEl = group.avatar_url + ? `` + : ''; + + return ` +
  • + + ${avatarEl} + ${group.name} + +
  • + `; + } + + roleRowHtml(role, isActive) { + const isActiveClass = isActive || ''; + + return ` +
  • + + ${role.text} + +
  • + `; + } +} diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js new file mode 100644 index 00000000000..fadb1f4f178 --- /dev/null +++ b/app/assets/javascripts/projects/settings/constants.js @@ -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; diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js new file mode 100644 index 00000000000..a17ae6811b7 --- /dev/null +++ b/app/assets/javascripts/protected_branches/constants.js @@ -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; diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js deleted file mode 100644 index 41e295387ae..00000000000 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ /dev/null @@ -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(); - }, - }); - } -} diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 16ecd5523d6..af1d1f1b31a 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -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'))); + } } diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 08d8c9919dd..239bd8e543a 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -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); + } } diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js index 10253c0febc..6ab9a126e76 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js @@ -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, }); }); } diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue index 98240aef810..365fc7ce6e9 100644 --- a/app/assets/javascripts/static_site_editor/components/app.vue +++ b/app/assets/javascripts/static_site_editor/components/app.vue @@ -1,3 +1,13 @@ + diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue deleted file mode 100644 index dd907570114..00000000000 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index 12aa301e02f..b7e5ea4eee3 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -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, + }, + }); }, }); }; diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index 123683b2833..f0d597d7c9b 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -1,12 +1,21 @@ diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index 9ecae87c1a9..b70f093e930 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -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() + : '' + ] || + '' + ); } diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 250abd7fc08..23f9a6a8f6c 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -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? diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index bcc74e8d1d9..4273130bbc2 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,3 +1,5 @@ +- add_page_startup_api_call discussions_path(@issue) + - @gfm_form = true - content_for :note_actions do diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 2a0dc5e30b9..a52f4d151c9 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -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 diff --git a/app/views/projects/static_site_editor/show.html.haml b/app/views/projects/static_site_editor/show.html.haml index 8d2649be588..2d817912335 100644 --- a/app/views/projects/static_site_editor/show.html.haml +++ b/app/views/projects/static_site_editor/show.html.haml @@ -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') }) } diff --git a/changelogs/unreleased/216868-improve-success-screen.yml b/changelogs/unreleased/216868-improve-success-screen.yml new file mode 100644 index 00000000000..d2aaf21508e --- /dev/null +++ b/changelogs/unreleased/216868-improve-success-screen.yml @@ -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 diff --git a/changelogs/unreleased/225888-fix-download-icon.yml b/changelogs/unreleased/225888-fix-download-icon.yml new file mode 100644 index 00000000000..9a4185d601a --- /dev/null +++ b/changelogs/unreleased/225888-fix-download-icon.yml @@ -0,0 +1,5 @@ +--- +title: Fix misalignment of download icon on jobs page +merge_request: 37966 +author: +type: other diff --git a/changelogs/unreleased/230689-remove-feature-flag-for-reference-filter.yml b/changelogs/unreleased/230689-remove-feature-flag-for-reference-filter.yml new file mode 100644 index 00000000000..9e67c13ceb5 --- /dev/null +++ b/changelogs/unreleased/230689-remove-feature-flag-for-reference-filter.yml @@ -0,0 +1,5 @@ +--- +title: Improve performance of Banzai reference filters +merge_request: 37465 +author: +type: performance diff --git a/changelogs/unreleased/231488-file-icons-case-insensitive-extension.yml b/changelogs/unreleased/231488-file-icons-case-insensitive-extension.yml new file mode 100644 index 00000000000..edda2f791af --- /dev/null +++ b/changelogs/unreleased/231488-file-icons-case-insensitive-extension.yml @@ -0,0 +1,5 @@ +--- +title: Make file icons extension detection be case-insensitive +merge_request: 37817 +author: +type: fixed diff --git a/changelogs/unreleased/doc-iam-role-ambiguity.yml b/changelogs/unreleased/doc-iam-role-ambiguity.yml new file mode 100644 index 00000000000..754be38f597 --- /dev/null +++ b/changelogs/unreleased/doc-iam-role-ambiguity.yml @@ -0,0 +1,5 @@ +--- +title: Adds clarifying documentation on EKS IAM roles +merge_request: 37870 +author: +type: added diff --git a/config/initializers/elastic_client_setup.rb b/config/initializers/elastic_client_setup.rb index 21745bd81d8..5b8d81265ad 100644 --- a/config/initializers/elastic_client_setup.rb +++ b/config/initializers/elastic_client_setup.rb @@ -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 diff --git a/doc/ci/pipelines/job_artifacts.md b/doc/ci/pipelines/job_artifacts.md index c4457d17dc2..a099dc371d2 100644 --- a/doc/ci/pipelines/job_artifacts.md +++ b/doc/ci/pipelines/job_artifacts.md @@ -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) diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index f6fdd382ee5..8be43d77a0e 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -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: diff --git a/doc/integration/jenkins.md b/doc/integration/jenkins.md index ddcfc3b1e9e..3ebb68ebeaa 100644 --- a/doc/integration/jenkins.md +++ b/doc/integration/jenkins.md @@ -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 diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md index 90f4782e0be..d2e0d88490d 100644 --- a/doc/user/clusters/applications.md +++ b/doc/user/clusters/applications.md @@ -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 diff --git a/doc/user/project/clusters/add_eks_clusters.md b/doc/user/project/clusters/add_eks_clusters.md index b11483a7446..f0b6fe81c18 100644 --- a/doc/user/project/clusters/add_eks_clusters.md +++ b/doc/user/project/clusters/add_eks_clusters.md @@ -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 diff --git a/doc/user/project/clusters/securing.md b/doc/user/project/clusters/securing.md index 12836d9153b..5b9f776080b 100644 --- a/doc/user/project/clusters/securing.md +++ b/doc/user/project/clusters/securing.md @@ -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 diff --git a/doc/user/upgrade_email_bypass.md b/doc/user/upgrade_email_bypass.md index 5f1e02b1383..bdc49e09916 100644 --- a/doc/user/upgrade_email_bypass.md +++ b/doc/user/upgrade_email_bypass.md @@ -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. diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index 9032ca6ddc6..9afcfee2fe8 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -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 diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index baa760bdc92..c34030063d6 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -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( diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fd739397a27..fb84eeab17b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 won’t 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 "" diff --git a/rubocop/cop/usage_data/large_table.rb b/rubocop/cop/usage_data/large_table.rb new file mode 100644 index 00000000000..d9d44f74d26 --- /dev/null +++ b/rubocop/cop/usage_data/large_table.rb @@ -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 diff --git a/rubocop/rubocop-usage-data.yml b/rubocop/rubocop-usage-data.yml new file mode 100644 index 00000000000..887d7aa9427 --- /dev/null +++ b/rubocop/rubocop-usage-data.yml @@ -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 diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js new file mode 100644 index 00000000000..6d323b0408b --- /dev/null +++ b/spec/frontend/projects/settings/access_dropdown_spec.js @@ -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(` +
    + +
    + `); + 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: '', + username: 'test', + }; + const template = dropdown.userRowHtml(user); + + expect(template).not.toContain(user.name); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/components/app_spec.js b/spec/frontend/static_site_editor/components/app_spec.js new file mode 100644 index 00000000000..bbdffeae68f --- /dev/null +++ b/spec/frontend/static_site_editor/components/app_spec.js @@ -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: '
    ', + }; + 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, + }); + }); +}); diff --git a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js deleted file mode 100644 index a63c3a83395..00000000000 --- a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js +++ /dev/null @@ -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); - }); -}); diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js index d62b67bfa83..3e19e2413e7 100644 --- a/spec/frontend/static_site_editor/pages/success_spec.js +++ b/spec/frontend/static_site_editor/pages/success_spec.js @@ -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(''); }); }); diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index adf0da21f9f..e55449dc684 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -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 }); diff --git a/spec/lib/banzai/filter/reference_filter_spec.rb b/spec/lib/banzai/filter/reference_filter_spec.rb index d5978db13c0..2888965dbc4 100644 --- a/spec/lib/banzai/filter/reference_filter_spec.rb +++ b/spec/lib/banzai/filter/reference_filter_spec.rb @@ -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('foo') } - let(:filter) { described_class.new(document, project: project) } + include_context 'new nodes' + let(:document) { Nokogiri::HTML.fragment('foo') } + 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('foo') - 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 diff --git a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb index beb760637b0..247f4591632 100644 --- a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb @@ -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) diff --git a/spec/policies/design_management/design_policy_spec.rb b/spec/policies/design_management/design_policy_spec.rb index 5dde5f896c9..50e1c86dc6b 100644 --- a/spec/policies/design_management/design_policy_spec.rb +++ b/spec/policies/design_management/design_policy_spec.rb @@ -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 diff --git a/spec/rubocop/cop/usage_data/large_table_spec.rb b/spec/rubocop/cop/usage_data/large_table_spec.rb new file mode 100644 index 00000000000..de6fb9c17e2 --- /dev/null +++ b/spec/rubocop/cop/usage_data/large_table_spec.rb @@ -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 diff --git a/spec/support/protected_branch_helpers.rb b/spec/support/protected_branch_helpers.rb index ede16d1c1e2..b34b9ec4641 100644 --- a/spec/support/protected_branch_helpers.rb +++ b/spec/support/protected_branch_helpers.rb @@ -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 diff --git a/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb b/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb index 65db082505a..a46382bc292 100644 --- a/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb +++ b/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb @@ -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)