diff options
Diffstat (limited to 'app')
34 files changed, 430 insertions, 159 deletions
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 } } |