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