diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-08 18:12:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-08 18:12:51 +0300 |
commit | f2ac9ee99ac6b1afc0edbc8621a05176dddd6a14 (patch) | |
tree | 6245fd9661e7c5d85fbe78676dd212734713b1f4 | |
parent | af97e4dd4beb0ba1aa0cb3c31df413333cbce77d (diff) |
Add latest changes from gitlab-org/gitlab@master
68 files changed, 1123 insertions, 875 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 156ef774529..c98aa0f5258 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -a47a975ef7d4ef51e0d68c5662d5cb3bb5b83b76 +4f0a07ba39f14adacf7d482128a5de2bb84f0eac diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index 52901d4c5bb..c83000e0be7 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -1,4 +1,5 @@ import dateFormat from 'dateformat'; +import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { dateFormats } from './constants'; export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => { @@ -7,3 +8,45 @@ export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'na }; export const toYmd = (date) => dateFormat(date, dateFormats.isoDate); + +/** + * Takes a url and extracts query parameters used for the shared + * filter bar + * + * @param {string} url The URL to extract query parameters from + * @returns {Object} + */ +export const extractFilterQueryParameters = (url = '') => { + const { + source_branch_name = null, + target_branch_name = null, + author_username = null, + milestone_title = null, + assignee_username = [], + label_name = [], + } = urlQueryToFilter(url); + + return { + selectedSourceBranch: source_branch_name, + selectedTargetBranch: target_branch_name, + selectedAuthor: author_username, + selectedMilestone: milestone_title, + selectedAssigneeList: assignee_username, + selectedLabelList: label_name, + }; +}; + +/** + * Takes a url and extracts sorting and pagination query parameters into an object + * + * @param {string} url The URL to extract query parameters from + * @returns {Object} + */ +export const extractPaginationQueryParameters = (url = '') => { + const { sort, direction, page } = urlQueryToFilter(url); + return { + sort: sort?.value || null, + direction: direction?.value || null, + page: page?.value || null, + }; +}; diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index b1227fb3533..59905035257 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -38,23 +38,9 @@ $.fn.requiresInput = function requiresInput() { $form.on('change input', fieldSelector, requireInput); }; -// Hide or Show the help block when creating a new project -// based on the option selected -function hideOrShowHelpBlock(form) { - const selected = $('.js-select-namespace option:selected'); - if (selected.length && selected.data('optionsParent') === 'groups') { - form.find('.form-text.text-muted').hide(); - } else if (selected.length) { - form.find('.form-text.text-muted').show(); - } -} - $(() => { $('form.js-requires-input').each((i, el) => { const $form = $(el); - $form.requiresInput(); - hideOrShowHelpBlock($form); - $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); }); }); diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 1c0dab11392..f4a27dc7d1f 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -5,8 +5,8 @@ import { canCreateConfidentialMergeRequest, } from './confidential_merge_request'; import confidentialMergeRequestState from './confidential_merge_request/state'; -import DropLab from './droplab/drop_lab'; -import ISetter from './droplab/plugins/input_setter'; +import DropLab from './filtered_search/droplab/drop_lab_deprecated'; +import ISetter from './filtered_search/droplab/plugins/input_setter'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { __, sprintf } from './locale'; diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 545719ee681..9726b2164b7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,6 +1,6 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import AjaxFilter from '../droplab/plugins/ajax_filter'; +import AjaxFilter from './droplab/plugins/ajax_filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index a7648a3c463..5adc074b3ce 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,7 +1,7 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import Ajax from '../droplab/plugins/ajax'; -import Filter from '../droplab/plugins/filter'; +import Ajax from './droplab/plugins/ajax'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 47f350dc6a2..9d29782c9a7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,5 +1,5 @@ -import Filter from '~/droplab/plugins/filter'; import { __ } from '~/locale'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index f78644a3893..ddc3c06a9d1 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,7 +1,7 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import Ajax from '../droplab/plugins/ajax'; -import Filter from '../droplab/plugins/filter'; +import Ajax from './droplab/plugins/ajax'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js index f933338514a..fb9f25a8c45 100644 --- a/app/assets/javascripts/filtered_search/dropdown_operator.js +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -1,5 +1,5 @@ -import Filter from '~/droplab/plugins/filter'; import { __ } from '~/locale'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/filtered_search/droplab/constants.js index 6451af49d36..6451af49d36 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/filtered_search/droplab/constants.js diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js index 05b741af191..05b741af191 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js index 6f068aaa800..15c4a4b7c6b 100644 --- a/app/assets/javascripts/droplab/drop_lab.js +++ b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js @@ -1,3 +1,13 @@ +/** + * This library is deprecated and scheduled to be removed once the + * filtered_search component is replaced with GitLab's new Pajamas + * filter vue component. + * + * The documentation has been removed from the gitlab codebase but + * can still be found in the commit history here: + * https://gitlab.com/gitlab-org/gitlab/-/blob/28f20e28/doc/development/fe_guide/droplab/droplab.md + */ + import { DATA_TRIGGER } from './constants'; import HookButton from './hook_button'; import HookInput from './hook_input'; diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/filtered_search/droplab/hook.js index 8a8dcde9f88..8a8dcde9f88 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/filtered_search/droplab/hook.js diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js index c51d6167fa3..c51d6167fa3 100644 --- a/app/assets/javascripts/droplab/hook_button.js +++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js index c523dae347f..c523dae347f 100644 --- a/app/assets/javascripts/droplab/hook_input.js +++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/filtered_search/droplab/keyboard.js index fe1ea2fa6b0..fe1ea2fa6b0 100644 --- a/app/assets/javascripts/droplab/keyboard.js +++ b/app/assets/javascripts/filtered_search/droplab/keyboard.js diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js index 77d60454d1a..77d60454d1a 100644 --- a/app/assets/javascripts/droplab/plugins/ajax.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js index ac4d44adc17..d0f2d205bb6 100644 --- a/app/assets/javascripts/droplab/plugins/ajax_filter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js @@ -1,5 +1,6 @@ /* eslint-disable */ -import AjaxCache from '../../lib/utils/ajax_cache'; + +import AjaxCache from '~/lib/utils/ajax_cache'; const AjaxFilter = { init: function (hook) { diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js index 06391668928..06391668928 100644 --- a/app/assets/javascripts/droplab/plugins/filter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js index 148d9a35b81..148d9a35b81 100644 --- a/app/assets/javascripts/droplab/plugins/input_setter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js index d7f49bf19d8..d7f49bf19d8 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/filtered_search/droplab/utils.js diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index ebaa3ef98b1..e467e97dda9 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,6 +1,6 @@ import { last } from 'lodash'; import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings'; -import DropLab from '~/droplab/drop_lab'; +import DropLab from './droplab/drop_lab_deprecated'; import { DROPDOWN_TYPE } from './constants'; import FilteredSearchContainer from './container'; import DropdownUtils from './dropdown_utils'; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js deleted file mode 100644 index af7a600d1ad..00000000000 --- a/app/assets/javascripts/namespace_select.js +++ /dev/null @@ -1,58 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Api from './api'; -import { mergeUrlParams } from './lib/utils/url_utility'; -import { __ } from './locale'; - -export default class NamespaceSelect { - constructor(opts) { - const isFilter = parseBoolean(opts.dropdown.dataset.isFilter); - const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id'; - - initDeprecatedJQueryDropdown($(opts.dropdown), { - filterable: true, - selectable: true, - filterRemote: true, - search: { - fields: ['path'], - }, - fieldName, - toggleLabel(selected) { - if (selected.id == null) { - return selected.text; - } - return `${selected.kind}: ${selected.full_path}`; - }, - data(term, dataCallback) { - return Api.namespaces(term, (namespaces) => { - if (isFilter) { - const anyNamespace = { - text: __('Any namespace'), - id: null, - }; - namespaces.unshift(anyNamespace); - namespaces.splice(1, 0, { type: 'divider' }); - } - return dataCallback(namespaces); - }); - }, - text(namespace) { - if (namespace.id == null) { - return namespace.text; - } - return `${namespace.kind}: ${namespace.full_path}`; - }, - renderRow: this.renderRow, - clicked(options) { - if (!isFilter) { - const { e } = options; - e.preventDefault(); - } - }, - url(namespace) { - return mergeUrlParams({ [fieldName]: namespace.id }, window.location.href); - }, - }); - } -} diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue new file mode 100644 index 00000000000..c75c031b0b1 --- /dev/null +++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue @@ -0,0 +1,143 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlLoadingIcon, +} from '@gitlab/ui'; +import Api from '~/api'; +import { __ } from '~/locale'; + +export default { + i18n: { + dropdownHeader: __('Namespaces'), + searchPlaceholder: __('Search for Namespace'), + anyNamespace: __('Any namespace'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlLoadingIcon, + GlSearchBoxByType, + }, + props: { + showAny: { + type: Boolean, + required: false, + default: false, + }, + placeholder: { + type: String, + required: false, + default: __('Namespace'), + }, + fieldName: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + namespaceOptions: [], + selectedNamespaceId: null, + selectedNamespace: null, + searchTerm: '', + isLoading: false, + }; + }, + computed: { + selectedNamespaceName() { + if (this.selectedNamespaceId === null) { + return this.placeholder; + } + return this.selectedNamespace; + }, + }, + watch: { + searchTerm() { + this.fetchNamespaces(this.searchTerm); + }, + }, + mounted() { + this.fetchNamespaces(); + }, + methods: { + fetchNamespaces(filter) { + this.isLoading = true; + this.namespaceOptions = []; + return Api.namespaces(filter, (namespaces) => { + this.namespaceOptions = namespaces; + this.isLoading = false; + }); + }, + selectNamespace(key) { + this.selectedNamespaceId = this.namespaceOptions[key].id; + this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]); + this.$emit('setNamespace', this.selectedNamespaceId); + }, + selectAnyNamespace() { + this.selectedNamespaceId = null; + this.selectedNamespace = null; + this.$emit('setNamespace', null); + }, + getNamespaceString(namespace) { + return `${namespace.kind}: ${namespace.full_path}`; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <input + v-if="fieldName" + :name="fieldName" + :value="selectedNamespaceId" + type="hidden" + data-testid="hidden-input" + /> + <gl-dropdown + :text="selectedNamespaceName" + :header-text="$options.i18n.dropdownHeader" + toggle-class="dropdown-menu-toggle large" + data-testid="namespace-dropdown" + :right="true" + > + <template #header> + <gl-search-box-by-type + v-model.trim="searchTerm" + class="namespace-search-box" + debounce="250" + :placeholder="$options.i18n.searchPlaceholder" + /> + </template> + + <template v-if="showAny"> + <gl-dropdown-item @click="selectAnyNamespace"> + {{ $options.i18n.anyNamespace }} + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + + <gl-loading-icon v-if="isLoading" /> + + <gl-dropdown-item + v-for="(namespace, key) in namespaceOptions" + :key="namespace.id" + @click="selectNamespace(key)" + > + {{ getNamespaceString(namespace) }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> + +<style scoped> +/* workaround position: relative imposed by .top-area .nav-controls */ +.namespace-search-box >>> input { + position: static; +} +</style> diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index b07ca815f13..3098d06510b 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,8 +1,38 @@ -import NamespaceSelect from '~/namespace_select'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import ProjectsList from '~/projects_list'; +import NamespaceSelect from './components/namespace_select.vue'; new ProjectsList(); // eslint-disable-line no-new -document - .querySelectorAll('.js-namespace-select') - .forEach((dropdown) => new NamespaceSelect({ dropdown })); +function mountNamespaceSelect() { + const el = document.querySelector('.js-namespace-select'); + if (!el) { + return false; + } + + const { showAny, fieldName, placeholder, updateLocation } = el.dataset; + + return new Vue({ + el, + render(createComponent) { + return createComponent(NamespaceSelect, { + props: { + showAny: parseBoolean(showAny), + fieldName, + placeholder, + }, + on: { + setNamespace(newNamespace) { + if (fieldName && updateLocation) { + window.location = mergeUrlParams({ [fieldName]: newNamespace }, window.location.href); + } + }, + }, + }); + }, + }); +} + +mountNamespaceSelect(); diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 35b001f8ef9..9823b0229a0 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -5,11 +5,10 @@ import { GlDropdownSectionHeader, GlDropdownDivider, GlSearchBoxByType, - GlLoadingIcon, GlAvatar, GlSprintf, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash'; import createFlash from '~/flash'; import { __, s__, n__ } from '~/locale'; import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api'; @@ -32,7 +31,6 @@ export default { GlDropdownSectionHeader, GlDropdownDivider, GlSearchBoxByType, - GlLoadingIcon, GlAvatar, GlSprintf, }, @@ -50,10 +48,26 @@ export default { type: Boolean, default: true, }, + label: { + type: String, + required: false, + default: i18n.selectUsers, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + preselectedItems: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { loading: false, + initialLoading: false, query: '', users: [], groups: [], @@ -68,6 +82,9 @@ export default { }; }, computed: { + preselected() { + return groupBy(this.preselectedItems, 'type'); + }, showDeployKeys() { return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length; }, @@ -105,10 +122,18 @@ export default { labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); } - return labelPieces.join(', ') || i18n.selectUsers; + return labelPieces.join(', ') || this.label; }, toggleClass() { - return this.toggleLabel === i18n.selectUsers ? 'gl-text-gray-500!' : ''; + return this.toggleLabel === this.label ? 'gl-text-gray-500!' : ''; + }, + selection() { + return [ + ...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'), + ...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'), + ...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'), + ...this.getDataForSave(LEVEL_TYPES.DEPLOY_KEY, 'deploy_key_id'), + ]; }, }, watch: { @@ -117,14 +142,14 @@ export default { }, 500), }, created() { - this.getData(); + this.getData({ initial: true }); }, - methods: { focusInput() { this.$refs.search.focusInput(); }, - getData() { + getData({ initial = false } = {}) { + this.initialLoading = initial; this.loading = true; if (this.hasLicense) { @@ -133,20 +158,26 @@ export default { getUsers(this.query), this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), ]) - .then(([deployKeysResponse, usersResponse, groupsResponse]) => - this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data), - ) + .then(([deployKeysResponse, usersResponse, groupsResponse]) => { + this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data); + this.setSelected({ initial }); + }) .catch(() => createFlash({ message: __('Failed to load groups, users and deploy keys.') }), ) .finally(() => { + this.initialLoading = false; this.loading = false; }); } else { getDeployKeys(this.query) - .then((deployKeysResponse) => this.consolidateData(deployKeysResponse.data)) + .then((deployKeysResponse) => { + this.consolidateData(deployKeysResponse.data); + this.setSelected({ initial }); + }) .catch(() => createFlash({ message: __('Failed to load deploy keys.') })) .finally(() => { + this.initialLoading = false; this.loading = false; }); } @@ -159,7 +190,13 @@ export default { if (this.hasLicense) { this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP })); - this.users = usersResponse.map((user) => ({ ...user, type: LEVEL_TYPES.USER })); + this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({ + id, + name, + username, + avatar_url, + type: LEVEL_TYPES.USER, + })); } this.deployKeys = deployKeysResponse.map((response) => { @@ -182,12 +219,84 @@ export default { }; }); }, + setSelected({ initial } = {}) { + if (initial) { + // as all available groups && roles are always visible in the dropdown, we set local selected by looking + // for intersection in all roles/groups and initial selected (returned from BE). + // It is different for the users - not all the users will be returned on the first data load (another set + // will be returned on search, only first 20 are displayed initially). + // That is why we set ALL initial selected users (returned from BE) as local selected (not looking + // for the intersection with all users data) and later if the selected happens to be in the users list + // we filter it out from the list so that not to have duplicates + // TODO: we'll need to get back to how to handle deploy keys here but they are out of scope + // and will be checked when migrating protected branches access dropdown to the current component + // related issue - https://gitlab.com/gitlab-org/gitlab/-/issues/284784 + const selectedRoles = intersectionWith( + this.roles, + this.preselectedItems, + (role, selected) => { + return selected.type === LEVEL_TYPES.ROLE && role.id === selected.access_level; + }, + ); + this.selected[LEVEL_TYPES.ROLE] = selectedRoles; + + const selectedGroups = intersectionWith( + this.groups, + this.preselectedItems, + (group, selected) => { + return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id; + }, + ); + this.selected[LEVEL_TYPES.GROUP] = selectedGroups; + + const selectedDeployKeys = intersectionWith( + this.deployKeys, + this.preselectedItems, + (key, selected) => { + return selected.type === LEVEL_TYPES.DEPLOY_KEY && key.id === selected.deploy_key_id; + }, + ); + this.selected[LEVEL_TYPES.DEPLOY_KEY] = selectedDeployKeys; + + const selectedUsers = this.preselectedItems + .filter(({ type }) => type === LEVEL_TYPES.USER) + .map(({ user_id, name, username, avatar_url, type }) => ({ + id: user_id, + name, + username, + avatar_url, + type, + })); + + this.selected[LEVEL_TYPES.USER] = selectedUsers; + } + + this.users = this.users.filter( + (user) => !this.selected[LEVEL_TYPES.USER].some((selected) => selected.id === user.id), + ); + this.users.unshift(...this.selected[LEVEL_TYPES.USER]); + }, + getDataForSave(accessType, key) { + const selected = this.selected[accessType].map(({ id }) => ({ [key]: id })); + const preselected = this.preselected[accessType]; + const added = differenceBy(selected, preselected, key); + const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({ + id, + [key]: keyId, + })); + const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({ + id, + [key]: keyId, + _destroy: true, + })); + return [...added, ...removed, ...preserved]; + }, onItemClick(item) { this.toggleSelection(this.selected[item.type], item); this.emitUpdate(); }, toggleSelection(arr, item) { - const itemIndex = arr.indexOf(item); + const itemIndex = arr.findIndex(({ id }) => id === item.id); if (itemIndex > -1) { arr.splice(itemIndex, 1); } else arr.push(item); @@ -196,8 +305,10 @@ export default { return this.selected[item.type].some((selected) => selected.id === item.id); }, emitUpdate() { - const selected = Object.values(this.selected).flat(); - this.$emit('select', selected); + this.$emit('select', this.selection); + }, + onHide() { + this.$emit('hidden', this.selection); }, }, }; @@ -205,15 +316,16 @@ export default { <template> <gl-dropdown + :disabled="disabled || initialLoading" :text="toggleLabel" - class="gl-display-block" + class="gl-min-w-20" :toggle-class="toggleClass" aria-labelledby="allowed-users-label" @shown="focusInput" + @hidden="onHide" > <template #header> - <gl-search-box-by-type ref="search" v-model.trim="query" /> - <gl-loading-icon v-if="loading" size="sm" /> + <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" /> </template> <template v-if="roles.length"> <gl-dropdown-section-header>{{ @@ -221,7 +333,8 @@ export default { }}</gl-dropdown-section-header> <gl-dropdown-item v-for="role in roles" - :key="role.id" + :key="`${role.id}${role.text}`" + data-testid="role-dropdown-item" is-check-item :is-checked="isSelected(role)" @click.native.capture.stop="onItemClick(role)" @@ -237,7 +350,9 @@ export default { }}</gl-dropdown-section-header> <gl-dropdown-item v-for="group in groups" - :key="group.id" + :key="`${group.id}${group.name}`" + fingerprint + data-testid="group-dropdown-item" :avatar-url="group.avatar_url" is-check-item :is-checked="isSelected(group)" @@ -254,7 +369,8 @@ export default { }}</gl-dropdown-section-header> <gl-dropdown-item v-for="user in users" - :key="user.id" + :key="`${user.id}${user.username}`" + data-testid="user-dropdown-item" :avatar-url="user.avatar_url" :secondary-text="user.username" is-check-item @@ -272,7 +388,8 @@ export default { }}</gl-dropdown-section-header> <gl-dropdown-item v-for="key in deployKeys" - :key="key.id" + :key="`${key.id}${key.fingerprint}`" + data-testid="deploy_key-dropdown-item" is-check-item :is-checked="isSelected(key)" class="gl-text-truncate" diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js index 9944b05d206..11272652b63 100644 --- a/app/assets/javascripts/projects/settings/init_access_dropdown.js +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import Vue from 'vue'; import AccessDropdown from './components/access_dropdown.vue'; @@ -7,6 +8,13 @@ export const initAccessDropdown = (el, options) => { } const { accessLevelsData, accessLevel } = options; + const { label, disabled, preselectedItems } = el.dataset; + let preselected = []; + try { + preselected = JSON.parse(preselectedItems); + } catch (e) { + Sentry.captureException(e); + } return new Vue({ el, @@ -16,6 +24,9 @@ export const initAccessDropdown = (el, options) => { props: { accessLevel, accessLevelsData: accessLevelsData.roles, + preselectedItems: preselected, + label, + disabled, }, on: { select(selected) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index c4e5fa06cd6..0da622448f9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -7,7 +7,7 @@ import { GlSafeHtmlDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import { EXTENSION_ICON_CLASS } from '../../constants'; import StatusIcon from './status_icon.vue'; @@ -42,6 +42,12 @@ export default { }; }, computed: { + widgetLabel() { + return this.$options.i18n?.label || this.$options.name; + }, + widgetLoadingText() { + return this.$options.i18n?.loading || __('Loading...'); + }, isLoadingSummary() { return this.loadingState === LOADING_STATES.collapsedLoading; }, @@ -60,7 +66,7 @@ export default { this.isCollapsed ? s__('mrWidget|Show %{widget} details') : s__('mrWidget|Hide %{widget} details'), - { widget: this.$options.label || this.$options.name }, + { widget: this.widgetLabel }, ); }, statusIconName() { @@ -119,24 +125,15 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> <div class="media gl-p-5"> - <status-icon - :name="$options.label || $options.name" - :is-loading="isLoadingSummary" - :icon-name="statusIconName" - /> + <status-icon :name="widgetLabel" :is-loading="isLoadingSummary" :icon-name="statusIconName" /> <div class="media-body gl-display-flex gl-align-self-center gl-align-items-center gl-flex-direction-row!" > <div class="gl-flex-grow-1"> - <template v-if="isLoadingSummary"> - {{ __('Loading...') }} - </template> + <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <div v-else v-safe-html="summary(collapsedData)"></div> </div> - <actions - :widget="$options.label || $options.name" - :tertiary-buttons="tertiaryActionsButtons" - /> + <actions :widget="widgetLabel" :tertiary-buttons="tertiaryActionsButtons" /> <div class="gl-float-right gl-align-self-center gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index bb40e22fe3f..4ca0b660696 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -11,6 +11,7 @@ export const registerExtension = (extension) => { extends: ExtensionBase, name: extension.name, props: extension.props, + i18n: extension.i18n, computed: { ...Object.keys(extension.computed).reduce( (acc, computedKey) => ({ diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 21e0b95431b..08c9172cc5b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -6,8 +6,11 @@ import issuesQuery from './issues.query.graphql'; export default { // Give the extension a name // Make it easier to track in Vue dev tools - name: 'Issues', - label: 'Issues', + name: 'WidgetIssues', + i18n: { + label: 'Issues', + loading: 'Loading issues...', + }, // Add an array of props // These then get mapped to values stored in the MR Widget store props: ['targetProjectFullPath', 'conflictsDocsPath'], diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index 50fc11ec7f3..82e56cf8b81 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -6,29 +6,24 @@ .form-check = f.check_box :authorized_keys_enabled, class: 'form-check-input' = f.label :authorized_keys_enabled, class: 'form-check-label' do - = _('Write to "authorized_keys" file') + = _('Use authorized_keys file to authenticate SSH keys') .form-text.text-muted - By default, we write to the "authorized_keys" file to support Git - over SSH without additional configuration. GitLab can be optimized - to authenticate SSH keys via the database file. Only uncheck this - if you have configured your OpenSSH server to use the - AuthorizedKeysCommand. Click on the help icon for more details. - = link_to sprite_icon('question-o'), help_page_path('administration/operations/fast_ssh_key_lookup') - + = _('Authenticate user SSH keys without requiring additional configuration. Performance of GitLab can be improved by using the GitLab database instead.') + = link_to _('How do I configure authentication using the GitLab database?'), help_page_path('administration/operations/fast_ssh_key_lookup'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold' = f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.') + = _('Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling.') .form-group = f.label :push_event_hooks_limit, class: 'label-bold' = f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.") + = _('Maximum number of changes (branches or tags) in a single push for which webhooks and services trigger (default is 3).') .form-group = f.label :push_event_activities_limit, class: 'label-bold' = f.number_field :push_event_activities_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.') + = _('Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3).') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index f30e8eb0d54..f947e174990 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -11,18 +11,14 @@ .nav-controls .search-holder = render 'shared/projects/search_form', autofocus: true, admin_view: true - .dropdown - - toggle_text = _('Namespace') - - if params[:namespace_id].present? - = hidden_field_tag :namespace_id, params[:namespace_id] - - namespace = Namespace.find(params[:namespace_id]) - - toggle_text = "#{namespace.kind}: #{namespace.full_path}" - = dropdown_toggle(toggle_text, { toggle: 'dropdown', is_filter: 'true' }, { toggle_class: 'js-namespace-select large' }) - .dropdown-menu.dropdown-select.dropdown-menu-right - = dropdown_title(_('Namespaces')) - = dropdown_filter(_("Search for Namespace")) - = dropdown_content - = dropdown_loading + - current_namespace = _('Namespace') + - if params[:namespace_id].present? + - namespace = Namespace.find(params[:namespace_id]) + - current_namespace = "#{namespace.kind}: #{namespace.full_path}" + %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { show_any: 'true', field_name: 'namespace_id', placeholder: current_namespace, update_location: 'true' }, type: 'button' } + %span.gl-new-dropdown-button-text + = current_namespace + = render 'shared/projects/dropdown' = link_to new_project_path, class: 'gl-button btn btn-confirm' do = _('New Project') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 1a87b21351c..3069aab2710 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -143,13 +143,10 @@ .col-sm-3.col-form-label = f.label :new_namespace_id, _("Namespace") .col-sm-9 - .dropdown - = dropdown_toggle(_('Search for Namespace'), { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) - .dropdown-menu.dropdown-select - = dropdown_title(_('Namespaces')) - = dropdown_filter(_('Search for Namespace')) - = dropdown_content - = dropdown_loading + - placeholder = _('Search for Namespace') + %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { field_name: 'new_namespace_id', placeholder: placeholder }, type: 'button' } + %span.gl-new-dropdown-button-text + = placeholder .form-group.row .offset-sm-3.col-sm-9 diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml index 9b728e7a89b..6412972e195 100644 --- a/app/views/clusters/clusters/_namespace.html.haml +++ b/app/views/clusters/clusters/_namespace.html.haml @@ -1,7 +1,6 @@ - managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, logs, and Web terminals.') -- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', - anchor: 'gitlab-managed-clusters'), target: '_blank' +- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank' .js-namespace-prefixed = platform_field.text_field :namespace, diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index cfeceb4f463..4b8a71a8e29 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -15,7 +15,7 @@ provider_type: @cluster.provider_type, help_path: help_page_path('user/project/clusters/index.md'), environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'), - clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), + clusters_help_path: help_page_path('user/project/clusters/deploy_to_cluster.md'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), cluster_id: @cluster.id } } diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md index e30ad4d8248..8aa5af4c2bf 100644 --- a/doc/administration/operations/fast_ssh_key_lookup.md +++ b/doc/administration/operations/fast_ssh_key_lookup.md @@ -104,12 +104,12 @@ In the case of lookup failures (which are common), the `authorized_keys` file is still scanned. So Git SSH performance would still be slow for many users as long as a large file exists. -To disable any more writes to the `authorized_keys` file: +To disable writes to the `authorized_keys` file: 1. On the top bar, select **Menu > Admin**. 1. On the left sidebar, select **Settings > Network**. 1. Expand **Performance optimization**. -1. Clear the **Write to "authorized_keys" file** checkbox. +1. Clear the **Use authorized_keys file to authenticate SSH keys** checkbox. 1. Select **Save changes**. Again, confirm that SSH is working by removing your user's SSH key in the UI, @@ -123,10 +123,14 @@ or for asking users to re-add their keys. This is a brief overview. Please refer to the above instructions for more context. -1. [Rebuild the `authorized_keys` file](../raketasks/maintenance.md#rebuild-authorized_keys-file) -1. Enable writes to the `authorized_keys` file in Application Settings +1. [Rebuild the `authorized_keys` file](../raketasks/maintenance.md#rebuild-authorized_keys-file). +1. Enable writes to the `authorized_keys` file. + 1. On the top bar, select **Menu > Admin**. + 1. On the left sidebar, select **Settings > Network**. + 1. Expand **Performance optimization**. + 1. Select the **Use authorized_keys file to authenticate SSH keys** checkbox. 1. Remove the `AuthorizedKeysCommand` lines from `/etc/ssh/sshd_config` or from `/assets/sshd_config` if you are using Omnibus Docker. -1. Reload `sshd`: `sudo service sshd reload` +1. Reload `sshd`: `sudo service sshd reload`. ## Compiling a custom version of OpenSSH for CentOS 6 diff --git a/doc/ci/environments/index.md b/doc/ci/environments/index.md index 9e4819b8ad8..8e5184e2229 100644 --- a/doc/ci/environments/index.md +++ b/doc/ci/environments/index.md @@ -181,7 +181,7 @@ associated with your project, you can configure these deployments from your NOTE: Kubernetes configuration isn't supported for Kubernetes clusters that are -[managed by GitLab](../../user/project/clusters/index.md#gitlab-managed-clusters). +[managed by GitLab](../../user/project/clusters/gitlab_managed_clusters.md). To follow progress on support for GitLab-managed clusters, see the [relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/38054). diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index 67b2144c160..2a3ce29cb7b 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -2247,7 +2247,7 @@ For more information, see NOTE: Kubernetes configuration is not supported for Kubernetes clusters -that are [managed by GitLab](../../user/project/clusters/index.md#gitlab-managed-clusters). +that are [managed by GitLab](../../user/project/clusters/gitlab_managed_clusters.md). To follow progress on support for GitLab-managed clusters, see the [relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/38054). diff --git a/doc/development/documentation/img/manual_build_docs.png b/doc/development/documentation/img/manual_build_docs_v14_3.png Binary files differindex e366a2f7ec4..e366a2f7ec4 100644 --- a/doc/development/documentation/img/manual_build_docs.png +++ b/doc/development/documentation/img/manual_build_docs_v14_3.png diff --git a/doc/development/documentation/review_apps.md b/doc/development/documentation/review_apps.md index 2b8c412f165..a5094ea87f0 100644 --- a/doc/development/documentation/review_apps.md +++ b/doc/development/documentation/review_apps.md @@ -26,7 +26,7 @@ to render and preview the documentation locally. If a merge request has documentation changes, use the `review-docs-deploy` manual job to deploy the documentation review app for your merge request. -![Manual trigger a documentation review app](img/manual_build_docs.png) +![Manual trigger a documentation review app](img/manual_build_docs_v14_3.png) The `review-docs-deploy*` job triggers a cross project pipeline and builds the docs site with your changes. When the pipeline finishes, the review app URL diff --git a/doc/development/fe_guide/droplab/droplab.md b/doc/development/fe_guide/droplab/droplab.md deleted file mode 100644 index 8f1ecc115fe..00000000000 --- a/doc/development/fe_guide/droplab/droplab.md +++ /dev/null @@ -1,281 +0,0 @@ ---- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments ---- - -# DropLab - -A generic dropdown for all of your custom dropdown needs. - -## Usage - -DropLab can be used by adding a `data-dropdown-trigger` HTML attribute. This -attribute allows us to find the "trigger" _(toggle)_ for the dropdown, whether -it's a button, link or input. - -The value of the `data-dropdown-trigger` should be a CSS selector that DropLab -can use to find the trigger's dropdown list. - -You should also add the `data-dropdown` attribute to declare the dropdown list. -The value is irrelevant. - -The DropLab class has no side effects, so you must always call `.init` when the -DOM is ready. `DropLab.prototype.init` takes the same arguments as `DropLab.prototype.addHook`. -If you don't provide any arguments, it globally queries and instantiates all -DropLab-compatible dropdowns. - -```html -<a href="#" data-dropdown-trigger="#list">Toggle</a> - -<ul id="list" data-dropdown> - <!-- ... --> -<ul> -``` - -```javascript -const droplab = new DropLab(); -droplab.init(); -``` - -As noted, we have a "Toggle" link that's declared as a trigger. It provides a -selector to find the dropdown list it should control. - -### Static data - -You can add static list items. - -```html -<a href="#" data-dropdown-trigger="#list">Toggle</a> - -<ul id="list" data-dropdown> - <li>Static value 1</li> - <li>Static value 2</li> -<ul> -``` - -```javascript -const droplab = new DropLab(); -droplab.init(); -``` - -### Explicit instantiation - -You can pass the trigger and list elements as constructor arguments to return a -non-global instance of DropLab using the `DropLab.prototype.init` method. - -```html -<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> - -<ul id="list" data-dropdown> - <!-- ... --> -<ul> -``` - -```javascript -const trigger = document.getElementById('trigger'); -const list = document.getElementById('list'); - -const droplab = new DropLab(); -droplab.init(trigger, list); -``` - -You can also add hooks to an existing DropLab instance using `DropLab.prototype.addHook`. - -```html -<a href="#" data-dropdown-trigger="#auto-dropdown">Toggle</a> -<ul id="auto-dropdown" data-dropdown><!-- ... --><ul> - -<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> -<ul id="list" data-dropdown><!-- ... --><ul> -``` - -```javascript -const droplab = new DropLab(); - -droplab.init(); - -const trigger = document.getElementById('trigger'); -const list = document.getElementById('list'); - -droplab.addHook(trigger, list); -``` - -### Dynamic data - -Adding `data-dynamic` to your dropdown element enables dynamic list -rendering. - -You can template a list item using the keys of the data object provided. Use the -handlebars syntax `{{ value }}` to HTML escape the value. Use the `<%= value %>` -syntax to interpolate the value. Use the `<%= value %>` syntax to evaluate the -value. - -Passing an array of objects to `DropLab.prototype.addData` renders that data -for all `data-dynamic` dropdown lists tracked by that DropLab instance. - -```html -<a href="#" data-dropdown-trigger="#list">Toggle</a> - -<ul id="list" data-dropdown data-dynamic> - <li><a href="#" data-id="{{id}}">{{text}}</a></li> -</ul> -``` - -```javascript -const droplab = new DropLab(); - -droplab.init().addData([{ - id: 0, - text: 'Jacob', -}, { - id: 1, - text: 'Jeff', -}]); -``` - -Alternatively, you can specify a specific dropdown to add this data to by -passing the data as the second argument and the `id` of the trigger element as -the first argument. - -```html -<a href="#" data-dropdown-trigger="#list" id="trigger">Toggle</a> - -<ul id="list" data-dropdown data-dynamic> - <li><a href="#" data-id="{{id}}">{{text}}</a></li> -</ul> -``` - -```javascript -const droplab = new DropLab(); - -droplab.init().addData('trigger', [{ - id: 0, - text: 'Jacob', -}, { - id: 1, - text: 'Jeff', -}]); -``` - -This allows you to mix static and dynamic content, even with one trigger. - -Note the use of scoping regarding the `data-dropdown` attribute to capture both -dropdown lists, one of which is dynamic. - -```html -<input id="trigger" data-dropdown-trigger="#list"> -<div id="list" data-dropdown> - <ul> - <li><a href="#">Static item 1</a></li> - <li><a href="#">Static item 2</a></li> - </ul> - <ul data-dynamic> - <li><a href="#" data-id="{{id}}">{{text}}</a></li> - </ul> -</div> -``` - -```javascript -const droplab = new DropLab(); - -droplab.init().addData('trigger', [{ - id: 0, - text: 'Jacob', -}, { - id: 1, - text: 'Jeff', -}]); -``` - -## Internal selectors - -DropLab adds some CSS classes to help lower the barrier to integration. - -For example: - -- The `droplab-item-selected` CSS class is added to items that have been - selected either by a mouse click or by enter key selection. -- The `droplab-item-active` CSS class is added to items that have been selected - using arrow key navigation. -- You can add the `droplab-item-ignore` CSS class to any item that you don't - want to be selectable. For example, an `<li class="divider"></li>` list - divider element that shouldn't be interactive. - -## Internal events - -DropLab uses some custom events to help lower the barrier to integration. - -For example: - -- The `click.dl` event is fired when an `li` list item has been clicked. It's - also fired when a list item has been selected with the keyboard. It's also - fired when a `HookButton` button is clicked (a registered `button` tag or `a` - tag trigger). -- The `input.dl` event is fired when a `HookInput` (a registered `input` tag - trigger) triggers an `input` event. -- The `mousedown.dl` event is fired when a `HookInput` triggers a `mousedown` - event. -- The `keyup.dl` event is fired when a `HookInput` triggers a `keyup` event. -- The `keydown.dl` event is fired when a `HookInput` triggers a `keydown` event. - -These custom events add a `detail` object to the vanilla `Event` object that -provides some potentially useful data. - -## Plugins - -Plugins are objects that are registered to be executed when a hook is added (when -a DropLab trigger and dropdown are instantiated). - -If no modules API is detected, the library falls back as it does with -`window.DropLab` and adds `window.DropLab.plugins.PluginName`. - -### Usage - -To use plugins, you can pass them in an array as the third argument of -`DropLab.prototype.init` or `DropLab.prototype.addHook`. Some plugins require -configuration values; the configuration object can be passed as the fourth argument. - -```html -<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> -<ul id="list" data-dropdown><!-- ... --><ul> -``` - -```javascript -const droplab = new DropLab(); - -const trigger = document.getElementById('trigger'); -const list = document.getElementById('list'); - -droplab.init(trigger, list, [droplabAjax], { - droplabAjax: { - endpoint: '/some-endpoint', - method: 'setData', - }, -}); -``` - -### Documentation - -Refer to the list of available [DropLab plugins](plugins/index.md) for -information about their use. - -### Development - -When plugins are initialised for a DropLab trigger+dropdown, DropLab calls the -plugins' `init` function, so this must be implemented in the plugin. - -```javascript -class MyPlugin { - static init() { - this.someProp = 'someProp'; - this.someMethod(); - } - - static someMethod() { - this.otherProp = 'otherProp'; - } -} - -export default MyPlugin; -``` diff --git a/doc/development/fe_guide/droplab/plugins/ajax.md b/doc/development/fe_guide/droplab/plugins/ajax.md deleted file mode 100644 index f12f8f260c7..00000000000 --- a/doc/development/fe_guide/droplab/plugins/ajax.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments ---- - -# Ajax plugin - -`Ajax` is a DropLab plugin that allows for retrieving and rendering list data -from a server. - -## Usage - -Add the `Ajax` object to the plugins array of a `DropLab.prototype.init` or -`DropLab.prototype.addHook` call. - -`Ajax` requires 2 configuration values: the `endpoint` and `method`. - -- `endpoint`: Should be a URL to the request endpoint. -- `method`: Should be `setData` or `addData`. -- `setData`: Completely replaces the dropdown with the response data. -- `addData`: Appends the response data to the current dropdown list. - -```html -<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> -<ul id="list" data-dropdown><!-- ... --><ul> -``` - -```javascript -const droplab = new DropLab(); - -const trigger = document.getElementById('trigger'); -const list = document.getElementById('list'); - -droplab.addHook(trigger, list, [Ajax], { - Ajax: { - endpoint: '/some-endpoint', - method: 'setData', - }, -}); -``` - -Optionally, you can set `loadingTemplate` to a HTML string. This HTML string -replaces the dropdown list while the request is pending. - -Additionally, you can set `onError` to a function to catch any XHR errors. diff --git a/doc/development/fe_guide/droplab/plugins/filter.md b/doc/development/fe_guide/droplab/plugins/filter.md deleted file mode 100644 index 79f10cdb6c1..00000000000 --- a/doc/development/fe_guide/droplab/plugins/filter.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments ---- - -# Filter plugin - -`Filter` is a DropLab plugin that allows for filtering data that has been added -to the dropdown using a simple fuzzy string search of an input value. - -## Usage - -Add the `Filter` object to the plugins array of a `DropLab.prototype.init` or -`DropLab.prototype.addHook` call. - -- `Filter`: Requires a configuration value for `template`. -- `template`: Should be the key of the objects within your data array that you - want to compare to the user input string, for filtering. - -```html -<input href="#" id="trigger" data-dropdown-trigger="#list"> -<ul id="list" data-dropdown data-dynamic> - <li><a href="#" data-id="{{id}}">{{text}}</a></li> -<ul> -``` - -```javascript -const droplab = new DropLab(); - -const trigger = document.getElementById('trigger'); -const list = document.getElementById('list'); - -droplab.init(trigger, list, [Filter], { - Filter: { - template: 'text', - }, -}); - -droplab.addData('trigger', [{ - id: 0, - text: 'Jacob', -}, { - id: 1, - text: 'Jeff', -}]); -``` - -In the previous code, the input string is compared against the `test` key of the -passed data objects. - -Optionally you can set `filterFunction` to a function. This function is then -used instead of `Filter`'s built-in string search. `filterFunction` is passed -two arguments: the first is one of the data objects, and the second is the -current input value. diff --git a/doc/development/fe_guide/droplab/plugins/index.md b/doc/development/fe_guide/droplab/plugins/index.md deleted file mode 100644 index c7a2865ca83..00000000000 --- a/doc/development/fe_guide/droplab/plugins/index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments -description: A list of DropLab plugins. ---- - -# DropLab plugins - -The following plugins are available for use with [DropLab](../droplab.md): - -- [Ajax plugin](ajax.md) -- [Filter plugin](filter.md) -- [InputSetter plugin](input_setter.md) diff --git a/doc/development/fe_guide/droplab/plugins/input_setter.md b/doc/development/fe_guide/droplab/plugins/input_setter.md deleted file mode 100644 index a3c073520cb..00000000000 --- a/doc/development/fe_guide/droplab/plugins/input_setter.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments ---- - -# InputSetter plugin - -`InputSetter` is a DropLab plugin that allows for updating DOM out of the scope -of DropLab when a list item is clicked. - -## Usage - -Add the `InputSetter` object to the plugins array of a `DropLab.prototype.init` -or `DropLab.prototype.addHook` call. - -- `InputSetter`: Requires a configuration value for `input` and `valueAttribute`. -- `input`: The DOM element that you want to manipulate. -- `valueAttribute`: A string that's the name of an attribute on your list items - that's used to get the value to update the `input` element with. - -You can also set the `InputSetter` configuration to an array of objects, which -allows you to update multiple elements. - -```html -<input id="input" value=""> -<div id="div" data-selected-id=""></div> - -<input href="#" id="trigger" data-dropdown-trigger="#list"> -<ul id="list" data-dropdown data-dynamic> - <li><a href="#" data-id="{{id}}">{{text}}</a></li> -<ul> -``` - -```javascript -const droplab = new DropLab(); - -const trigger = document.getElementById('trigger'); -const list = document.getElementById('list'); - -const input = document.getElementById('input'); -const div = document.getElementById('div'); - -droplab.init(trigger, list, [InputSetter], { - InputSetter: [{ - input: input, - valueAttribute: 'data-id', - } { - input: div, - valueAttribute: 'data-id', - inputAttribute: 'data-selected-id', - }], -}); - -droplab.addData('trigger', [{ - id: 0, - text: 'Jacob', -}, { - id: 1, - text: 'Jeff', -}]); -``` - -In the previous code, if the second list item was clicked, it would update the -`#input` element to have a `value` of `1`, it would also update the `#div` -element's `data-selected-id` to `1`. - -Optionally, you can set `inputAttribute` to a string that's the name of an -attribute on your `input` element that you want to update. If you don't provide -an `inputAttribute`, `InputSetter` updates the `value` of the `input` -element if it's an `INPUT` element, or the `textContent` of the `input` element -if it isn't an `INPUT` element. diff --git a/doc/operations/metrics/embed.md b/doc/operations/metrics/embed.md index e40e8864319..e84c190e08d 100644 --- a/doc/operations/metrics/embed.md +++ b/doc/operations/metrics/embed.md @@ -99,14 +99,14 @@ a chart corresponding to the query can be included if these requirements are met > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/40997) in GitLab 12.9. > - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/208224) from GitLab Ultimate to GitLab Free in 13.2. -[Cluster Health Metrics](../../user/project/clusters/index.md#visualizing-cluster-health) +[Cluster Health Metrics](../../user/infrastructure/clusters/manage/clusters_health.md) can also be embedded in [GitLab-flavored Markdown](../../user/markdown.md). To embed a metric chart, include a link to that chart in the form `https://<root_url>/<project>/-/cluster/<cluster_id>?<query_params>` anywhere that GitLab-flavored Markdown is supported. To generate and copy a link to the chart, follow the instructions in the -[Cluster Health Metric documentation](../../user/project/clusters/index.md#visualizing-cluster-health). +[Cluster Health Metric documentation](../../user/infrastructure/clusters/manage/clusters_health.md). The following requirements must be met for the metric to unfurl: @@ -114,7 +114,7 @@ The following requirements must be met for the metric to unfurl: - Prometheus must be monitoring the cluster. - The user must be allowed access to the project cluster metrics. - The dashboards must be reporting data on the - [Cluster Health Page](../../user/project/clusters/index.md#visualizing-cluster-health) + [Cluster Health Page](../../user/infrastructure/clusters/manage/clusters_health.md) If the above requirements are met, then the metric unfurls as seen below. diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md index 59c61da0c45..4ee412f5c65 100644 --- a/doc/topics/autodevops/quick_start_guide.md +++ b/doc/topics/autodevops/quick_start_guide.md @@ -103,7 +103,7 @@ to deploy this project to. [Cloud Run](../../user/project/clusters/add_gke_clusters.md#cloud-run-for-anthos), Istio, and HTTP Load Balancing add-ons for this cluster. - **GitLab-managed cluster** - Select this checkbox to - [allow GitLab to manage namespace and service accounts](../../user/project/clusters/index.md#gitlab-managed-clusters) for this cluster. + [allow GitLab to manage namespace and service accounts](../../user/project/clusters/gitlab_managed_clusters.md) for this cluster. 1. Select **Create Kubernetes cluster**. diff --git a/doc/user/clusters/environments.md b/doc/user/clusters/environments.md index cad55f0cf0b..470f65db61b 100644 --- a/doc/user/clusters/environments.md +++ b/doc/user/clusters/environments.md @@ -33,7 +33,7 @@ owners](../permissions.md#group-members-permissions) In order to: - Track environments for the cluster, you must - [deploy to a Kubernetes cluster](../project/clusters/index.md#deploying-to-a-kubernetes-cluster) + [deploy to a Kubernetes cluster](../project/clusters/deploy_to_cluster.md) successfully. - Show pod usage correctly, you must [enable deploy boards](../project/deploy_boards.md#enabling-deploy-boards). diff --git a/doc/user/infrastructure/clusters/index.md b/doc/user/infrastructure/clusters/index.md new file mode 100644 index 00000000000..16ca6d02865 --- /dev/null +++ b/doc/user/infrastructure/clusters/index.md @@ -0,0 +1,66 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Kubernetes clusters **(FREE)** + +> - Project-level clusters [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/35954) in GitLab 10.1. +> - Group-level clusters [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/34758) in GitLab 11.6. +> - Instance-level clusters [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/39840) in GitLab 11.11. + +Kubernetes is a container orchestration platform to deploy applications +in a cluster without downtime and that scales as you need. + +With the GitLab integration with Kubernetes, you can: + +1. [Connect your cluster](#connect-your-cluster-to-gitlab). +1. [Manage your cluster](#manage-your-cluster). +1. [Deploy your cluster](#deploy-to-your-cluster). + +See the [Kubernetes clusters versions supported by GitLab](connect/index.md#supported-cluster-versions). + +## Connect your cluster to GitLab + +Learn how to [create new and connect existing clusters to GitLab](connect/index.md). + +## Manage your cluster + +- [Cluster Management Project](../../clusters/management_project.md): +create a project to manage your cluster's shared resources requiring +`cluster-admin` privileges such as an Ingress controller. + - [Cluster Management Project Template](../../clusters/management_project_template.md): start a cluster management project directly from a template. + - [Migrate to Cluster Management Project](../../clusters/migrating_from_gma_to_project_template.md): migrate from the deprecated GitLab Managed Apps to Cluster Management Projects. + - [GitLab Managed Apps](../../clusters/applications.md) (deprecated in favor of Cluster Management Projects): configure applications in your cluster directly from GitLab. +- [Cluster integrations](../../clusters/integrations.md): install +third-party applications into your cluster and manage them from GitLab. +- [GitLab-managed clusters](../../project/clusters/gitlab_managed_clusters.md): +enable GitLab to automatically create resources for your clusters. +- [Cost management](../../clusters/cost_management.md): see insights into your cluster's resource usage. +- [Crossplane integration](../../clusters/crossplane.md): manage your cluster's resources and cloud infrastructure with Crossplane. + +### Monitor your cluster + +- [Prometheus monitoring](../../project/integrations/prometheus_library/kubernetes.md): detect and monitor Kubernetes metrics with Prometheus. +- [NGINX monitoring](../../project/integrations/prometheus_library/nginx.md): automatically monitor NGINX Ingress. +- [Clusters health](manage/clusters_health.md): monitor your cluster's health, such as CPU and memory usage. + +### Secure your cluster + +- [Container Host Security](../../project/clusters/protect/container_host_security/index.md): monitor and block activity inside a container and enforce security policies across the cluster. +- [Container Network security](../../project/clusters/protect/container_network_security/index.md): filter traffic going in and out of the cluster and traffic between pods through a firewall with Cilium NetworkPolicies. + +## Deploy to your cluster + +- [CI/CD Tunnel](../../clusters/agent/ci_cd_tunnel.md): use the CI/CD Tunnel to run Kubernetes commands from different projects. +- [Inventory object](deploy/inventory_object.md): track objects applied to a cluster configured with the Kubernetes Agent. +- [Auto DevOps](../../../topics/autodevops/index.md): enable Auto DevOps +to allow GitLab automatically detect, build, test, and deploy applications. +- [Cluster environments](../../clusters/environments.md): view CI/CD environments deployed to Kubernetes clusters. +- [Canary Deployments](../../project/canary_deployments.md): deploy app updates to a small portion of the fleet with this Continuous Delivery strategy. +- [Deploy to your cluster](../../project/clusters/deploy_to_cluster.md): +deploy applications into your cluster using cluster certificates. +- [Deploy Boards](../../project/deploy_boards.md): view the current health and status of each CI/CD environment running on your cluster, and the status of deployment pods. +- [Pod logs](../../project/clusters/kubernetes_pod_logs.md): view the logs of your cluster's running pods. +- [Serverless](../../project/clusters/serverless/index.md) (deprecated): deploy Serverless applications in Kubernetes environments and cloud Function as a Service (FaaS) environments. diff --git a/doc/user/infrastructure/clusters/manage/clusters_health.md b/doc/user/infrastructure/clusters/manage/clusters_health.md new file mode 100644 index 00000000000..009945589ad --- /dev/null +++ b/doc/user/infrastructure/clusters/manage/clusters_health.md @@ -0,0 +1,14 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Clusters health **(FREE)** + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/4701) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.6. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/208224) to GitLab Free in 13.2. + +When [the Prometheus cluster integration is enabled](../../../clusters/integrations.md#prometheus-cluster-integration), GitLab monitors the cluster's health. At the top of the cluster settings page, CPU and Memory utilization is displayed, along with the total amount available. Keeping an eye on cluster resources can be important, if the cluster runs out of memory pods may be shutdown or fail to start. + +![Cluster Monitoring](img/k8s_cluster_monitoring.png) diff --git a/doc/user/project/clusters/img/k8s_cluster_monitoring.png b/doc/user/infrastructure/clusters/manage/img/k8s_cluster_monitoring.png Binary files differindex 0a8c5043c65..0a8c5043c65 100644 --- a/doc/user/project/clusters/img/k8s_cluster_monitoring.png +++ b/doc/user/infrastructure/clusters/manage/img/k8s_cluster_monitoring.png diff --git a/doc/user/project/clusters/add_existing_cluster.md b/doc/user/project/clusters/add_existing_cluster.md index 505c493de4e..3347ef9a437 100644 --- a/doc/user/project/clusters/add_existing_cluster.md +++ b/doc/user/project/clusters/add_existing_cluster.md @@ -188,7 +188,7 @@ To add a Kubernetes cluster to your project, group, or instance: ``` 1. **GitLab-managed cluster** - Leave this checked if you want GitLab to manage namespaces and service accounts for this cluster. - See the [Managed clusters section](index.md#gitlab-managed-clusters) for more information. + See the [Managed clusters section](gitlab_managed_clusters.md) for more information. 1. **Project namespace** (optional) - You don't have to fill this in. By leaving it blank, GitLab creates one for you. Also: - Each project should have a unique namespace. diff --git a/doc/user/project/clusters/cluster_access.md b/doc/user/project/clusters/cluster_access.md index 7bf202f6963..452f5727620 100644 --- a/doc/user/project/clusters/cluster_access.md +++ b/doc/user/project/clusters/cluster_access.md @@ -32,7 +32,7 @@ The resources created by GitLab differ depending on the type of cluster. Note the following about access controls: - Environment-specific resources are only created if your cluster is - [managed by GitLab](index.md#gitlab-managed-clusters). + [managed by GitLab](gitlab_managed_clusters.md). - If your cluster was created before GitLab 12.2, it uses a single namespace for all project environments. diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 791dc90cad5..ac59f874244 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -4,77 +4,22 @@ group: Configure info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- -# Kubernetes clusters **(FREE)** +# Project-level Kubernetes clusters **(FREE)** -> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/35954) in GitLab 10.1 for projects. -> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/34758) in -> GitLab 11.6 for [groups](../../group/clusters/index.md). -> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/39840) in -> GitLab 11.11 for [instances](../../instance/clusters/index.md). +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/35954) in GitLab 10.1. -We offer extensive integrations to help you connect and manage your Kubernetes clusters from GitLab. +[Project-level Kubernetes clusters](../../infrastructure/clusters/connect/index.md#cluster-levels) +allow you to connect a Kubernetes cluster to a project in GitLab. -Read through this document to get started. +You can also [connect multiple clusters](multiple_kubernetes_clusters.md) +to a single project. -## Benefit from the GitLab-Kubernetes integration +After connecting a cluster to GitLab, you can benefit from the large number of +[GitLab features available for Kubernetes clusters](../../infrastructure/clusters/index.md) to manage and deploy to your cluster. -Using the GitLab-Kubernetes integration, you can benefit of GitLab -features such as: +## View your project-level clusters -- Create [CI/CD Pipelines](../../../ci/pipelines/index.md) to build, test, and deploy to your cluster. -- Use [Auto DevOps](#auto-devops) to automate the CI/CD process. -- Use [role-based or attribute-based access controls](cluster_access.md). -- Run serverless workloads on [Kubernetes with Knative](serverless/index.md). -- Connect GitLab to in-cluster applications using [cluster integrations](../../clusters/integrations.md). -- Use [deploy boards](../deploy_boards.md) to see the health and status of each CI [environment](../../../ci/environments/index.md) running on your Kubernetes cluster. -- Use [Canary deployments](../canary_deployments.md) to update only a portion of your fleet with the latest version of your application. -- View your [Kubernetes podlogs](kubernetes_pod_logs.md) directly in GitLab. -- Connect to your cluster through GitLab [web terminals](deploy_to_cluster.md#web-terminals-for-kubernetes-clusters). +To view project-level Kubernetes clusters: -## Supported cluster versions - -See the [Kubernetes clusters versions supported by GitLab](../../infrastructure/clusters/connect/index.md#supported-cluster-versions). - -## Connect your cluster to GitLab - -Learn how to [create new and connect existing clusters to GitLab](../../infrastructure/clusters/connect/index.md). - -## Cluster integrations - -See the available [cluster integrations](../../clusters/integrations.md) -to integrate third-party applications with your clusters through GitLab. - -## Cluster management project - -Attach a [Cluster management project](../../clusters/management_project.md) -to your cluster to manage shared resources requiring `cluster-admin` privileges for -installation, such as an Ingress controller. - -## GitLab-managed clusters - -See how to allow [GitLab to manage your cluster for you](gitlab_managed_clusters.md). - -## Auto DevOps - -You can use [Auto DevOps](../../../topics/autodevops/index.md) to automatically -detect, build, test, deploy, and monitor your applications. - -## Deploying to a Kubernetes cluster - -See how to [deploy to your Kubernetes cluster](deploy_to_cluster.md) from GitLab. - -## Monitoring your Kubernetes cluster - -Automatically detect and monitor Kubernetes metrics. Automatic monitoring of -[NGINX Ingress](../integrations/prometheus_library/nginx.md) is also supported. - -[Read more about Kubernetes monitoring](../integrations/prometheus_library/kubernetes.md) - -### Visualizing cluster health - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/4701) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.6. -> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/208224) to GitLab Free in 13.2. - -When [the Prometheus cluster integration is enabled](../../clusters/integrations.md#prometheus-cluster-integration), GitLab monitors the cluster's health. At the top of the cluster settings page, CPU and Memory utilization is displayed, along with the total amount available. Keeping an eye on cluster resources can be important, if the cluster runs out of memory pods may be shutdown or fail to start. - -![Cluster Monitoring](img/k8s_cluster_monitoring.png) +1. On the top bar, select **Menu > Projects** and find your project. +1. On the left sidebar, select **Infrastructure > Kubernetes clusters**. diff --git a/lib/gitlab/tracking/standard_context.rb b/lib/gitlab/tracking/standard_context.rb index 02f4efcad81..df62e8bbbe6 100644 --- a/lib/gitlab/tracking/standard_context.rb +++ b/lib/gitlab/tracking/standard_context.rb @@ -3,7 +3,7 @@ module Gitlab module Tracking class StandardContext - GITLAB_STANDARD_SCHEMA_URL = 'iglu:com.gitlab/gitlab_standard/jsonschema/1-0-5' + GITLAB_STANDARD_SCHEMA_URL = 'iglu:com.gitlab/gitlab_standard/jsonschema/1-0-7' GITLAB_RAILS_SOURCE = 'gitlab-rails' def initialize(namespace: nil, project: nil, user: nil, **extra) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index eaef6cf77e7..b11c0629d20 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1199,6 +1199,9 @@ msgstr[1] "" msgid "- Not available to run jobs." msgstr "" +msgid "- Select -" +msgstr "" + msgid "- User" msgid_plural "- Users" msgstr[0] "" @@ -4780,6 +4783,9 @@ msgstr "" msgid "Authenticate" msgstr "" +msgid "Authenticate user SSH keys without requiring additional configuration. Performance of GitLab can be improved by using the GitLab database instead." +msgstr "" + msgid "Authenticate with GitHub" msgstr "" @@ -8388,6 +8394,9 @@ msgstr "" msgid "Company" msgstr "" +msgid "Company Name" +msgstr "" + msgid "Compare" msgstr "" @@ -9510,6 +9519,9 @@ msgstr "" msgid "Couldn't assign policy to project" msgstr "" +msgid "Country" +msgstr "" + msgid "Coverage" msgstr "" @@ -14641,6 +14653,9 @@ msgstr "" msgid "Finished at" msgstr "" +msgid "First Name" +msgstr "" + msgid "First Seen" msgstr "" @@ -16841,9 +16856,6 @@ msgstr "" msgid "High or unknown vulnerabilities present" msgstr "" -msgid "Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0." -msgstr "" - msgid "Highest role:" msgstr "" @@ -16904,6 +16916,9 @@ msgstr "" msgid "How do I configure Akismet?" msgstr "" +msgid "How do I configure authentication using the GitLab database?" +msgstr "" + msgid "How do I configure it?" msgstr "" @@ -19914,6 +19929,9 @@ msgstr "" msgid "Last Activity" msgstr "" +msgid "Last Name" +msgstr "" + msgid "Last Pipeline" msgstr "" @@ -21053,6 +21071,9 @@ msgstr "" msgid "Maximum number of %{name} (%{count}) exceeded" msgstr "" +msgid "Maximum number of changes (branches or tags) in a single push for which webhooks and services trigger (default is 3)." +msgstr "" + msgid "Maximum number of comments exceeded" msgstr "" @@ -21062,6 +21083,9 @@ msgstr "" msgid "Maximum number of projects." msgstr "" +msgid "Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling." +msgstr "" + msgid "Maximum number of unique IP addresses per user." msgstr "" @@ -23441,18 +23465,15 @@ msgstr "" msgid "Number of LOCs per commit" msgstr "" -msgid "Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value." -msgstr "" - -msgid "Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value." -msgstr "" - msgid "Number of commits" msgstr "" msgid "Number of commits per MR" msgstr "" +msgid "Number of employees" +msgstr "" + msgid "Number of events" msgstr "" @@ -24028,6 +24049,36 @@ msgstr "" msgid "Owner" msgstr "" +msgid "PQL|An error occurred while sending hand raise lead." +msgstr "" + +msgid "PQL|By providing my contact information, I agree GitLab may contact me via email about its product, services and events. You may opt-out at any time by unsubscribing in emails or visiting our communication preference center." +msgstr "" + +msgid "PQL|Cancel" +msgstr "" + +msgid "PQL|Contact our Sales team" +msgstr "" + +msgid "PQL|Contact sales" +msgstr "" + +msgid "PQL|Hello %{userName}. Before putting you in touch with our sales team, we would like you to verify and complete the information below." +msgstr "" + +msgid "PQL|Message for the Sales team (optional)" +msgstr "" + +msgid "PQL|Please select a city or state" +msgstr "" + +msgid "PQL|Submit information" +msgstr "" + +msgid "PQL|Thank you for reaching out! Our sales team will bet back to you soon." +msgstr "" + msgid "Package Registry" msgstr "" @@ -32306,6 +32357,9 @@ msgstr "" msgid "State your message to activate" msgstr "" +msgid "State/Province/City" +msgstr "" + msgid "Static Application Security Testing (SAST)" msgstr "" @@ -32804,6 +32858,9 @@ msgstr "" msgid "Successfully updated %{last_updated_timeago}." msgstr "" +msgid "Successfully updated the environment." +msgstr "" + msgid "Suggest code changes which can be immediately applied in one click. Try it out!" msgstr "" @@ -33305,6 +33362,9 @@ msgstr "" msgid "TeamcityIntegration|Trigger TeamCity CI after every push to the repository, except branch delete" msgstr "" +msgid "Telephone number" +msgstr "" + msgid "Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}." msgstr "" @@ -35177,6 +35237,9 @@ msgstr "" msgid "Threshold in bytes at which to reject Sidekiq jobs. Set this to 0 to if you don't want to limit Sidekiq jobs." msgstr "" +msgid "Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3)." +msgstr "" + msgid "Throughput" msgstr "" @@ -36881,6 +36944,9 @@ msgstr "" msgid "Use an AWS CloudFormation Template (CFT) to install and configure GitLab Runner in AWS." msgstr "" +msgid "Use authorized_keys file to authenticate SSH keys" +msgstr "" + msgid "Use cURL" msgstr "" @@ -38596,9 +38662,6 @@ msgstr "" msgid "Write milestone description..." msgstr "" -msgid "Write to \"authorized_keys\" file" -msgstr "" - msgid "Write your release notes or drag your files hereā¦" msgstr "" @@ -39784,6 +39847,12 @@ msgstr "" msgid "ciReport|Base pipeline codequality artifact not found" msgstr "" +msgid "ciReport|Browser Performance" +msgstr "" + +msgid "ciReport|Browser performance test metrics results are being parsed" +msgstr "" + msgid "ciReport|Browser performance test metrics: " msgstr "" @@ -39875,6 +39944,9 @@ msgid_plural "ciReport|Load performance test metrics detected %{strongStart}%{ch msgstr[0] "" msgstr[1] "" +msgid "ciReport|Load performance test metrics results are being parsed" +msgstr "" + msgid "ciReport|Load performance test metrics: " msgstr "" diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index 15def00f354..a50ef34d327 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -96,7 +96,7 @@ RSpec.describe "Admin::Projects" do visit admin_project_path(project) click_button 'Search for Namespace' - click_link 'group: web' + click_button 'group: web' click_button 'Transfer' expect(page).to have_content("Web / #{project.name}") diff --git a/spec/frontend/analytics/shared/utils_spec.js b/spec/frontend/analytics/shared/utils_spec.js index e3293f2d8bd..aade749e0d2 100644 --- a/spec/frontend/analytics/shared/utils_spec.js +++ b/spec/frontend/analytics/shared/utils_spec.js @@ -1,4 +1,9 @@ -import { filterBySearchTerm } from '~/analytics/shared/utils'; +import { + filterBySearchTerm, + extractFilterQueryParameters, + extractPaginationQueryParameters, +} from '~/analytics/shared/utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; describe('filterBySearchTerm', () => { const data = [ @@ -22,3 +27,102 @@ describe('filterBySearchTerm', () => { expect(filterBySearchTerm(data, 'ne', 'title')).toEqual([data[0]]); }); }); + +describe('extractFilterQueryParameters', () => { + const selectedAuthor = 'Author 1'; + const selectedMilestone = 'Milestone 1.0'; + const selectedSourceBranch = 'main'; + const selectedTargetBranch = 'feature-1'; + const selectedAssigneeList = ['Alice', 'Bob']; + const selectedLabelList = ['Label 1', 'Label 2']; + + const queryParamsString = objectToQuery({ + source_branch_name: selectedSourceBranch, + target_branch_name: selectedTargetBranch, + author_username: selectedAuthor, + milestone_title: selectedMilestone, + assignee_username: selectedAssigneeList, + label_name: selectedLabelList, + }); + + it('extracts the correct filter parameters from a url', () => { + const result = extractFilterQueryParameters(queryParamsString); + const operator = '='; + const expectedFilters = { + selectedAssigneeList: { operator, value: selectedAssigneeList.join(',') }, + selectedLabelList: { operator, value: selectedLabelList.join(',') }, + selectedAuthor: { operator, value: selectedAuthor }, + selectedMilestone: { operator, value: selectedMilestone }, + selectedSourceBranch: { operator, value: selectedSourceBranch }, + selectedTargetBranch: { operator, value: selectedTargetBranch }, + }; + expect(result).toMatchObject(expectedFilters); + }); + + it('returns null for missing parameters', () => { + const result = extractFilterQueryParameters(''); + const expectedFilters = { + selectedAuthor: null, + selectedMilestone: null, + selectedSourceBranch: null, + selectedTargetBranch: null, + }; + expect(result).toMatchObject(expectedFilters); + }); + + it('only returns the parameters we expect', () => { + const result = extractFilterQueryParameters('foo="one"&bar="two"'); + const resultKeys = Object.keys(result); + ['foo', 'bar'].forEach((key) => { + expect(resultKeys).not.toContain(key); + }); + + [ + 'selectedAuthor', + 'selectedMilestone', + 'selectedSourceBranch', + 'selectedTargetBranch', + 'selectedAssigneeList', + 'selectedLabelList', + ].forEach((key) => { + expect(resultKeys).toContain(key); + }); + }); + + it('returns an empty array for missing list parameters', () => { + const result = extractFilterQueryParameters(''); + const expectedFilters = { selectedAssigneeList: [], selectedLabelList: [] }; + expect(result).toMatchObject(expectedFilters); + }); +}); + +describe('extractPaginationQueryParameters', () => { + const sort = 'title'; + const direction = 'asc'; + const page = '1'; + const queryParamsString = objectToQuery({ sort, direction, page }); + + it('extracts the correct filter parameters from a url', () => { + const result = extractPaginationQueryParameters(queryParamsString); + const expectedFilters = { sort, page, direction }; + expect(result).toMatchObject(expectedFilters); + }); + + it('returns null for missing parameters', () => { + const result = extractPaginationQueryParameters(''); + const expectedFilters = { sort: null, direction: null, page: null }; + expect(result).toMatchObject(expectedFilters); + }); + + it('only returns the parameters we expect', () => { + const result = extractPaginationQueryParameters('foo="one"&bar="two"&qux="three"'); + const resultKeys = Object.keys(result); + ['foo', 'bar', 'qux'].forEach((key) => { + expect(resultKeys).not.toContain(key); + }); + + ['sort', 'page', 'direction'].forEach((key) => { + expect(resultKeys).toContain(key); + }); + }); +}); diff --git a/spec/frontend/droplab/constants_spec.js b/spec/frontend/filtered_search/droplab/constants_spec.js index fd48228d6a2..9c1caf90ac0 100644 --- a/spec/frontend/droplab/constants_spec.js +++ b/spec/frontend/filtered_search/droplab/constants_spec.js @@ -1,4 +1,4 @@ -import * as constants from '~/droplab/constants'; +import * as constants from '~/filtered_search/droplab/constants'; describe('constants', () => { describe('DATA_TRIGGER', () => { diff --git a/spec/frontend/droplab/drop_down_spec.js b/spec/frontend/filtered_search/droplab/drop_down_spec.js index dcdbbcd4ccf..f49dbfcf79c 100644 --- a/spec/frontend/droplab/drop_down_spec.js +++ b/spec/frontend/filtered_search/droplab/drop_down_spec.js @@ -1,6 +1,6 @@ -import { SELECTED_CLASS } from '~/droplab/constants'; -import DropDown from '~/droplab/drop_down'; -import utils from '~/droplab/utils'; +import { SELECTED_CLASS } from '~/filtered_search/droplab/constants'; +import DropDown from '~/filtered_search/droplab/drop_down'; +import utils from '~/filtered_search/droplab/utils'; describe('DropLab DropDown', () => { let testContext; diff --git a/spec/frontend/droplab/hook_spec.js b/spec/frontend/filtered_search/droplab/hook_spec.js index 0b897a570f6..0d92170cfcf 100644 --- a/spec/frontend/droplab/hook_spec.js +++ b/spec/frontend/filtered_search/droplab/hook_spec.js @@ -1,7 +1,7 @@ -import DropDown from '~/droplab/drop_down'; -import Hook from '~/droplab/hook'; +import DropDown from '~/filtered_search/droplab/drop_down'; +import Hook from '~/filtered_search/droplab/hook'; -jest.mock('~/droplab/drop_down', () => jest.fn()); +jest.mock('~/filtered_search/droplab/drop_down', () => jest.fn()); describe('Hook', () => { let testContext; diff --git a/spec/frontend/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js index d442d5cf416..88b3fc236e4 100644 --- a/spec/frontend/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js @@ -1,4 +1,4 @@ -import AjaxFilter from '~/droplab/plugins/ajax_filter'; +import AjaxFilter from '~/filtered_search/droplab/plugins/ajax_filter'; import AjaxCache from '~/lib/utils/ajax_cache'; describe('AjaxFilter', () => { diff --git a/spec/frontend/droplab/plugins/ajax_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_spec.js index 7c6452e8337..c968b982091 100644 --- a/spec/frontend/droplab/plugins/ajax_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_spec.js @@ -1,4 +1,4 @@ -import Ajax from '~/droplab/plugins/ajax'; +import Ajax from '~/filtered_search/droplab/plugins/ajax'; import AjaxCache from '~/lib/utils/ajax_cache'; describe('Ajax', () => { diff --git a/spec/frontend/droplab/plugins/input_setter_spec.js b/spec/frontend/filtered_search/droplab/plugins/input_setter_spec.js index eebde018fa1..811b5ca4573 100644 --- a/spec/frontend/droplab/plugins/input_setter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/input_setter_spec.js @@ -1,4 +1,4 @@ -import InputSetter from '~/droplab/plugins/input_setter'; +import InputSetter from '~/filtered_search/droplab/plugins/input_setter'; describe('InputSetter', () => { let testContext; diff --git a/spec/frontend/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js deleted file mode 100644 index a38508dd601..00000000000 --- a/spec/frontend/namespace_select_spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import NamespaceSelect from '~/namespace_select'; - -jest.mock('~/deprecated_jquery_dropdown'); - -describe('NamespaceSelect', () => { - it('initializes deprecatedJQueryDropdown', () => { - const dropdown = document.createElement('div'); - - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - - expect(initDeprecatedJQueryDropdown).toHaveBeenCalled(); - }); - - describe('as input', () => { - let deprecatedJQueryDropdownOptions; - - beforeEach(() => { - const dropdown = document.createElement('div'); - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; - }); - - it('prevents click events', () => { - const dummyEvent = new Event('dummy'); - jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - - // expect(foo).toContain('test'); - deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); - - expect(dummyEvent.preventDefault).toHaveBeenCalled(); - }); - }); - - describe('as filter', () => { - let deprecatedJQueryDropdownOptions; - - beforeEach(() => { - const dropdown = document.createElement('div'); - dropdown.dataset.isFilter = 'true'; - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; - }); - - it('does not prevent click events', () => { - const dummyEvent = new Event('dummy'); - jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - - deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); - - expect(dummyEvent.preventDefault).not.toHaveBeenCalled(); - }); - - it('sets URL of dropdown items', () => { - const dummyNamespace = { id: 'eal' }; - - const itemUrl = deprecatedJQueryDropdownOptions.url(dummyNamespace); - - expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`); - }); - }); -}); diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js new file mode 100644 index 00000000000..c579aa2f2da --- /dev/null +++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils'; +import Api from '~/api'; +import NamespaceSelect from '~/pages/admin/projects/components/namespace_select.vue'; + +describe('Dropdown select component', () => { + let wrapper; + + const mountDropdown = (propsData) => { + wrapper = mount(NamespaceSelect, { propsData }); + }; + + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findNamespaceInput = () => wrapper.find('[data-testid="hidden-input"]'); + const findFilterInput = () => wrapper.find('.namespace-search-box input'); + const findDropdownOption = (match) => { + const buttons = wrapper + .findAll('button.dropdown-item') + .filter((node) => node.text().match(match)); + return buttons.length ? buttons.at(0) : buttons; + }; + + const setFieldValue = async (field, value) => { + await field.setValue(value); + field.trigger('blur'); + }; + + beforeEach(() => { + setFixtures('<div class="test-container"></div>'); + + jest.spyOn(Api, 'namespaces').mockImplementation((_, callback) => + callback([ + { id: 10, kind: 'user', full_path: 'Administrator' }, + { id: 20, kind: 'group', full_path: 'GitLab Org' }, + ]), + ); + }); + + it('creates a hidden input if fieldName is provided', () => { + mountDropdown({ fieldName: 'namespace-input' }); + + expect(findNamespaceInput()).toExist(); + expect(findNamespaceInput().attributes('name')).toBe('namespace-input'); + }); + + describe('clicking dropdown options', () => { + it('retrieves namespaces based on filter query', async () => { + mountDropdown(); + + await setFieldValue(findFilterInput(), 'test'); + + expect(Api.namespaces).toHaveBeenCalledWith('test', expect.anything()); + }); + + it('updates the dropdown value based upon selection', async () => { + mountDropdown({ fieldName: 'namespace-input' }); + + // wait for dropdown options to populate + await wrapper.vm.$nextTick(); + + expect(findDropdownOption('user: Administrator')).toExist(); + expect(findDropdownOption('group: GitLab Org')).toExist(); + expect(findDropdownOption('group: Foobar')).not.toExist(); + + findDropdownOption('user: Administrator').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(findNamespaceInput().attributes('value')).toBe('10'); + expect(findDropdownToggle().text()).toBe('user: Administrator'); + }); + + it('triggers a setNamespace event upon selection', async () => { + mountDropdown(); + + // wait for dropdown options to populate + await wrapper.vm.$nextTick(); + + findDropdownOption('group: GitLab Org').trigger('click'); + + expect(wrapper.emitted('setNamespace')).toHaveLength(1); + expect(wrapper.emitted('setNamespace')[0][0]).toBe(20); + }); + + it('displays "Any Namespace" option when showAny prop provided', () => { + mountDropdown({ showAny: true }); + expect(wrapper.text()).toContain('Any namespace'); + }); + + it('does not display "Any Namespace" option when showAny prop not provided', () => { + mountDropdown(); + expect(wrapper.text()).not.toContain('Any namespace'); + }); + }); +}); diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js index d8e8af9651a..a42891423cd 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -5,22 +5,33 @@ import { GlDropdownSectionHeader, GlSearchBoxByType, } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api'; import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue'; -import { ACCESS_LEVELS } from '~/projects/settings/constants'; +import { ACCESS_LEVELS, LEVEL_TYPES } from '~/projects/settings/constants'; jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ - getUsers: jest.fn().mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] }), - getGroups: jest.fn().mockResolvedValue({ data: [{ id: 3 }, { id: 4 }, { id: 5 }] }), + getGroups: jest.fn().mockResolvedValue({ + data: [ + { id: 4, name: 'group4' }, + { id: 5, name: 'group5' }, + { id: 6, name: 'group6' }, + ], + }), + getUsers: jest.fn().mockResolvedValue({ + data: [ + { id: 7, name: 'user7' }, + { id: 8, name: 'user8' }, + { id: 9, name: 'user9' }, + ], + }), getDeployKeys: jest.fn().mockResolvedValue({ data: [ - { id: 6, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } }, - { id: 7, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } }, - { id: 8, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } }, - { id: 9, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user4' } }, + { id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } }, + { id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } }, + { id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } }, ], }), })); @@ -29,21 +40,35 @@ describe('Access Level Dropdown', () => { let wrapper; const mockAccessLevelsData = [ { - id: 42, - text: 'Dummy Role', + id: 1, + text: 'role1', + }, + { + id: 2, + text: 'role2', + }, + { + id: 3, + text: 'role3', }, ]; const createComponent = ({ accessLevelsData = mockAccessLevelsData, accessLevel = ACCESS_LEVELS.PUSH, - hasLicense = true, + hasLicense, + label, + disabled, + preselectedItems, } = {}) => { - wrapper = shallowMount(AccessDropdown, { + wrapper = shallowMountExtended(AccessDropdown, { propsData: { accessLevelsData, accessLevel, hasLicense, + label, + disabled, + preselectedItems, }, stubs: { GlSprintf, @@ -52,12 +77,19 @@ describe('Access Level Dropdown', () => { }); }; + afterEach(() => { + wrapper.destroy(); + }); + const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownToggleLabel = () => findDropdown().props('text'); const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdownItemWithText = (items, text) => + items.filter((item) => item.text().includes(text)).at(0); + describe('data request', () => { it('should make an api call for users, groups && deployKeys when user has a license', () => { createComponent(); @@ -84,6 +116,7 @@ describe('Access Level Dropdown', () => { expect(getDeployKeys).toHaveBeenCalledWith(query); }); }); + describe('layout', () => { beforeEach(async () => { createComponent(); @@ -95,79 +128,88 @@ describe('Access Level Dropdown', () => { }); it('renders dropdown item for each access level type', () => { - expect(findAllDropdownItems()).toHaveLength(10); + expect(findAllDropdownItems()).toHaveLength(12); }); }); + describe('toggleLabel', () => { + let dropdownItems = []; beforeEach(async () => { createComponent(); await waitForPromises(); + dropdownItems = findAllDropdownItems(); }); - const triggerNthItemClick = async (n) => { - findAllDropdownItems().at(n).trigger('click'); + const findItemByNameAndClick = async (name) => { + findDropdownItemWithText(dropdownItems, name).trigger('click'); await nextTick(); }; - it('when no items selected displays a default label and has default CSS class ', () => { + it('when no items selected and custom label provided, displays it and has default CSS class', () => { + wrapper.destroy(); + const customLabel = 'Set the access level'; + createComponent({ label: customLabel }); + expect(findDropdownToggleLabel()).toBe(customLabel); + expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + }); + + it('when no items selected, displays a default fallback label and has default CSS class ', () => { expect(findDropdownToggleLabel()).toBe(i18n.selectUsers); expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); }); it('displays a number of selected items for each group level', async () => { - findAllDropdownItems().wrappers.forEach((item) => { + dropdownItems.wrappers.forEach((item) => { item.trigger('click'); }); await nextTick(); - expect(findDropdownToggleLabel()).toBe('1 role, 2 users, 4 deploy keys, 3 groups'); + expect(findDropdownToggleLabel()).toBe('3 roles, 3 users, 3 deploy keys, 3 groups'); }); it('with only role selected displays the role name and has no class applied', async () => { - await triggerNthItemClick(0); - expect(findDropdownToggleLabel()).toBe('Dummy Role'); + await findItemByNameAndClick('role1'); + expect(findDropdownToggleLabel()).toBe('role1'); expect(findDropdown().props('toggleClass')).toBe(''); }); it('with only groups selected displays the number of selected groups', async () => { - await triggerNthItemClick(1); - await triggerNthItemClick(2); - await triggerNthItemClick(3); + await findItemByNameAndClick('group4'); + await findItemByNameAndClick('group5'); + await findItemByNameAndClick('group6'); expect(findDropdownToggleLabel()).toBe('3 groups'); expect(findDropdown().props('toggleClass')).toBe(''); }); it('with only users selected displays the number of selected users', async () => { - await triggerNthItemClick(4); - await triggerNthItemClick(5); + await findItemByNameAndClick('user7'); + await findItemByNameAndClick('user8'); expect(findDropdownToggleLabel()).toBe('2 users'); expect(findDropdown().props('toggleClass')).toBe(''); }); it('with users and groups selected displays the number of selected users & groups', async () => { - await triggerNthItemClick(1); - await triggerNthItemClick(2); - await triggerNthItemClick(4); - await triggerNthItemClick(5); + await findItemByNameAndClick('group4'); + await findItemByNameAndClick('group6'); + await findItemByNameAndClick('user7'); + await findItemByNameAndClick('user9'); expect(findDropdownToggleLabel()).toBe('2 users, 2 groups'); expect(findDropdown().props('toggleClass')).toBe(''); }); it('with users and deploy keys selected displays the number of selected users & keys', async () => { - await triggerNthItemClick(1); - await triggerNthItemClick(2); - await triggerNthItemClick(6); - expect(findDropdownToggleLabel()).toBe('1 deploy key, 2 groups'); + await findItemByNameAndClick('user8'); + await findItemByNameAndClick('key10'); + await findItemByNameAndClick('key11'); + expect(findDropdownToggleLabel()).toBe('1 user, 2 deploy keys'); expect(findDropdown().props('toggleClass')).toBe(''); }); }); describe('selecting an item', () => { - beforeEach(async () => { + it('selects the item on click and deselects on the next click ', async () => { createComponent(); await waitForPromises(); - }); - it('selects the item on click and deselects on the next click ', async () => { const item = findAllDropdownItems().at(1); item.trigger('click'); await nextTick(); @@ -177,18 +219,101 @@ describe('Access Level Dropdown', () => { expect(item.props('isChecked')).toBe(false); }); - it('emits an update on selection ', async () => { + it('emits a formatted update on selection ', async () => { + // ids: the items appear in that order in the dropdown + // 1 2 3 - roles + // 4 5 6 - groups + // 7 8 9 - users + // 10 11 12 - deploy_keys + // we set 2 from each group as preselected. Then for the sake of the test deselect one, leave one as-is + // and select a new one from the group. + // Preselected items should have `id` along with `user_id/group_id/access_level/deplo_key_id`. + // Items to be removed from previous selection will have `_deploy` flag set to true + // Newly selected items will have only `user_id/group_id/access_level/deploy_key_id` (depending on their type); + const preselectedItems = [ + { id: 112, type: 'role', access_level: 2 }, + { id: 113, type: 'role', access_level: 3 }, + { id: 115, type: 'group', group_id: 5 }, + { id: 116, type: 'group', group_id: 6 }, + { id: 118, type: 'user', user_id: 8, name: 'user8' }, + { id: 119, type: 'user', user_id: 9, name: 'user9' }, + { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + { id: 122, type: 'deploy_key', deploy_key_id: 12 }, + ]; + + createComponent({ preselectedItems }); + await waitForPromises(); const spy = jest.spyOn(wrapper.vm, '$emit'); - findAllDropdownItems().at(4).trigger('click'); - findAllDropdownItems().at(3).trigger('click'); - await nextTick(); + const dropdownItems = findAllDropdownItems(); + // select new item from each group + findDropdownItemWithText(dropdownItems, 'role1').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group4').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user7').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key10').trigger('click'); + // deselect one item from each group + findDropdownItemWithText(dropdownItems, 'role2').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group5').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user8').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key11').trigger('click'); + expect(spy).toHaveBeenLastCalledWith('select', [ - { id: 5, type: 'group' }, - { id: 1, type: 'user' }, + { access_level: 1 }, + { id: 112, access_level: 2, _destroy: true }, + { id: 113, access_level: 3 }, + { group_id: 4 }, + { id: 115, group_id: 5, _destroy: true }, + { id: 116, group_id: 6 }, + { user_id: 7 }, + { id: 118, user_id: 8, _destroy: true }, + { id: 119, user_id: 9 }, + { deploy_key_id: 10 }, + { id: 121, deploy_key_id: 11, _destroy: true }, + { id: 122, deploy_key_id: 12 }, ]); }); }); + describe('Handling preselected items', () => { + const preselectedItems = [ + { id: 112, type: 'role', access_level: 2 }, + { id: 115, type: 'group', group_id: 5 }, + { id: 118, type: 'user', user_id: 8, name: 'user2' }, + { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + ]; + + const findSelected = (type) => + wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked')); + + beforeEach(async () => { + createComponent({ preselectedItems }); + await waitForPromises(); + }); + + it('should set selected roles as intersection between the server response and preselected', () => { + const selectedRoles = findSelected(LEVEL_TYPES.ROLE); + expect(selectedRoles).toHaveLength(1); + expect(selectedRoles.at(0).text()).toBe('role2'); + }); + + it('should set selected groups as intersection between the server response and preselected', () => { + const selectedGroups = findSelected(LEVEL_TYPES.GROUP); + expect(selectedGroups).toHaveLength(1); + expect(selectedGroups.at(0).text()).toBe('group5'); + }); + + it('should set selected users to all preselected mapping `user_id` to `id`', () => { + const selectedUsers = findSelected(LEVEL_TYPES.USER); + expect(selectedUsers).toHaveLength(1); + expect(selectedUsers.at(0).text()).toBe('user2'); + }); + + it('should set selected deploy keys as intersection between the server response and preselected mapping some keys', () => { + const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY); + expect(selectedDeployKeys).toHaveLength(1); + expect(selectedDeployKeys.at(0).text()).toContain('key11 (abcdefghijklmn...)'); + }); + }); + describe('on dropdown open', () => { beforeEach(() => { createComponent(); @@ -201,4 +326,20 @@ describe('Access Level Dropdown', () => { expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); }); }); + + describe('on dropdown close', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should emit `hidden` event with dropdown selection', () => { + jest.spyOn(wrapper.vm, '$emit'); + + findAllDropdownItems().at(1).trigger('click'); + + findDropdown().vm.$emit('hidden'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('hidden', [{ access_level: 2 }]); + }); + }); }); diff --git a/workhorse/internal/zipartifacts/open_archive_test.go b/workhorse/internal/zipartifacts/open_archive_test.go index f7624d053d9..ea1fc606784 100644 --- a/workhorse/internal/zipartifacts/open_archive_test.go +++ b/workhorse/internal/zipartifacts/open_archive_test.go @@ -2,56 +2,124 @@ package zipartifacts import ( "archive/zip" + "bytes" "context" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/stretchr/testify/require" ) -func TestOpenHTTPArchive(t *testing.T) { - const ( - zipFile = "test.zip" - entryName = "hello.txt" - contents = "world" - testRoot = "testdata/public" - ) - - require.NoError(t, os.MkdirAll(testRoot, 0755)) - f, err := os.Create(filepath.Join(testRoot, zipFile)) - require.NoError(t, err, "create file") +func createArchive(t *testing.T, dir string) (map[string][]byte, int64) { + f, err := os.Create(filepath.Join(dir, "test.zip")) + require.NoError(t, err) defer f.Close() - zw := zip.NewWriter(f) - w, err := zw.Create(entryName) - require.NoError(t, err, "create zip entry") - _, err = fmt.Fprint(w, contents) - require.NoError(t, err, "write zip entry contents") - require.NoError(t, zw.Close(), "close zip writer") - require.NoError(t, f.Close(), "close file") - - srv := httptest.NewServer(http.FileServer(http.Dir(testRoot))) + + entries := make(map[string][]byte) + for _, size := range []int{0, 32 * 1024, 128 * 1024, 5 * 1024 * 1024} { + entryName := fmt.Sprintf("file_%d", size) + entries[entryName] = bytes.Repeat([]byte{'z'}, size) + + w, err := zw.Create(entryName) + require.NoError(t, err) + + _, err = w.Write(entries[entryName]) + require.NoError(t, err) + } + + require.NoError(t, zw.Close()) + fi, err := f.Stat() + require.NoError(t, err) + require.NoError(t, f.Close()) + + return entries, fi.Size() +} + +func TestOpenHTTPArchive(t *testing.T) { + dir := t.TempDir() + entries, _ := createArchive(t, dir) + + srv := httptest.NewServer(http.FileServer(http.Dir(dir))) defer srv.Close() - zr, err := OpenArchive(context.Background(), srv.URL+"/"+zipFile) - require.NoError(t, err, "call OpenArchive") - require.Len(t, zr.File, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + zr, err := OpenArchive(ctx, srv.URL+"/test.zip") + require.NoError(t, err) + require.Len(t, zr.File, len(entries)) - zf := zr.File[0] - require.Equal(t, entryName, zf.Name, "zip entry name") + for _, zf := range zr.File { + entry, ok := entries[zf.Name] + require.True(t, ok) - entry, err := zf.Open() - require.NoError(t, err, "get zip entry reader") - defer entry.Close() + r, err := zf.Open() + require.NoError(t, err) - actualContents, err := ioutil.ReadAll(entry) - require.NoError(t, err, "read zip entry contents") - require.Equal(t, contents, string(actualContents), "compare zip entry contents") + contents, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, entry, contents) + + require.NoError(t, r.Close()) + } +} + +func TestMinimalRangeRequests(t *testing.T) { + if strings.HasPrefix(runtime.Version(), "go1.17") { + t.Skipf("skipped for go1.17: https://gitlab.com/gitlab-org/gitlab/-/issues/340778") + } + + dir := t.TempDir() + entries, archiveSize := createArchive(t, dir) + + mux := http.NewServeMux() + + var ranges []string + mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { + rangeHdr := r.Header.Get("Range") + if rangeHdr == "" { + rw.Header().Add("Content-Length", fmt.Sprintf("%d", archiveSize)) + return + } + + ranges = append(ranges, rangeHdr) + http.FileServer(http.Dir(dir)).ServeHTTP(rw, r) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + zr, err := OpenArchive(ctx, srv.URL+"/test.zip") + require.NoError(t, err) + require.Len(t, zr.File, len(entries)) + + require.Len(t, ranges, 2, "range requests should be minimal") + require.NotContains(t, ranges, "bytes=0-", "range request should not request from zero") + + for _, zf := range zr.File { + r, err := zf.Open() + require.NoError(t, err) + + _, err = io.Copy(io.Discard, r) + require.NoError(t, err) + + require.NoError(t, r.Close()) + } + + // ensure minimal requests: https://gitlab.com/gitlab-org/gitlab/-/issues/340778 + require.Len(t, ranges, 3, "range requests should be minimal") + require.Contains(t, ranges, "bytes=0-") } func TestOpenHTTPArchiveNotSendingAcceptEncodingHeader(t *testing.T) { @@ -64,5 +132,8 @@ func TestOpenHTTPArchiveNotSendingAcceptEncodingHeader(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(requestHandler)) defer srv.Close() - OpenArchive(context.Background(), srv.URL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + OpenArchive(ctx, srv.URL) } |