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