diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
commit | d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch) | |
tree | 2341ef426af70ad1e289c38036737e04b0aa5007 /app/assets/javascripts/projects | |
parent | d6e514dd13db8947884cd58fe2a9c2a063400a9b (diff) | |
download | gitlab-ce-d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb.tar.gz |
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'app/assets/javascripts/projects')
11 files changed, 982 insertions, 58 deletions
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue new file mode 100644 index 00000000000..6e9efc50be8 --- /dev/null +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -0,0 +1,124 @@ +<script> +import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg'; +import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg'; +import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg'; +import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; +import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; + +const CI_CD_PANEL = 'cicd_for_external_repo'; +const PANELS = [ + { + key: 'blank', + name: 'blank_project', + selector: '#blank-project-pane', + title: s__('ProjectsNew|Create blank project'), + description: s__( + 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.', + ), + illustration: blankProjectIllustration, + }, + { + key: 'template', + name: 'create_from_template', + selector: '#create-from-template-pane', + title: s__('ProjectsNew|Create from template'), + description: s__( + 'ProjectsNew|Create a project pre-populated with the necessary files to get you started quickly.', + ), + illustration: createFromTemplateIllustration, + }, + { + key: 'import', + name: 'import_project', + selector: '#import-project-pane', + title: s__('ProjectsNew|Import project'), + description: s__( + 'ProjectsNew|Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.', + ), + illustration: importProjectIllustration, + }, + { + key: 'ci', + name: CI_CD_PANEL, + selector: '#ci-cd-project-pane', + title: s__('ProjectsNew|Run CI/CD for external repository'), + description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'), + illustration: ciCdProjectIllustration, + }, +]; + +export default { + components: { + NewNamespacePage, + NewProjectPushTipPopover, + }, + directives: { + SafeHtml, + }, + props: { + hasErrors: { + type: Boolean, + required: false, + default: false, + }, + isCiCdAvailable: { + type: Boolean, + required: false, + default: false, + }, + newProjectGuidelines: { + type: String, + required: false, + default: '', + }, + }, + + computed: { + availablePanels() { + return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL); + }, + }, + + methods: { + resetProjectErrors() { + const errorsContainer = document.querySelector('.project-edit-errors'); + if (errorsContainer) { + errorsContainer.innerHTML = ''; + } + }, + }, +}; +</script> + +<template> + <new-namespace-page + :initial-breadcrumb="s__('New project')" + :panels="availablePanels" + :jump-to-last-persisted-panel="hasErrors" + :title="s__('ProjectsNew|Create new project')" + persistence-key="new_project_last_active_tab" + @panel-change="resetProjectErrors" + > + <template #extra-description> + <div + v-if="newProjectGuidelines" + id="new-project-guideline" + v-safe-html="newProjectGuidelines" + ></div> + </template> + <template #welcome-footer> + <div class="gl-pt-5 gl-text-center"> + <p> + {{ __('You can also create a project from the command line.') }} + <a ref="clipTip" href="#" @click.prevent> + {{ __('Show command') }} + </a> + <new-project-push-tip-popover :target="() => $refs.clipTip" /> + </p> + </div> + </template> + </new-namespace-page> +</template> diff --git a/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue new file mode 100644 index 00000000000..e42d9154866 --- /dev/null +++ b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue @@ -0,0 +1,66 @@ +<script> +import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + components: { + GlPopover, + GlFormInputGroup, + ClipboardButton, + }, + inject: ['pushToCreateProjectCommand', 'workingWithProjectsHelpPath'], + props: { + target: { + type: [Function, HTMLElement], + required: true, + }, + }, + i18n: { + clipboardButtonTitle: __('Copy command'), + commandInputAriaLabel: __('Push project from command line'), + helpLinkText: __('What does this command do?'), + labelText: __('Private projects can be created in your personal namespace with:'), + popoverTitle: __('Push to create a project'), + }, +}; +</script> +<template> + <gl-popover + :target="target" + :title="$options.i18n.popoverTitle" + triggers="click blur" + placement="top" + > + <p> + <label for="push-to-create-tip" class="gl-font-weight-normal"> + {{ $options.i18n.labelText }} + </label> + </p> + <p> + <gl-form-input-group + id="push-to-create-tip" + :value="pushToCreateProjectCommand" + readonly + select-on-click + :aria-label="$options.i18n.commandInputAriaLabel" + > + <template #append> + <clipboard-button + :text="pushToCreateProjectCommand" + :title="$options.i18n.clipboardButtonTitle" + tooltip-placement="right" + /> + </template> + </gl-form-input-group> + </p> + <p> + <a + :href="`${workingWithProjectsHelpPath}#push-to-create-a-new-project`" + class="gl-font-sm" + target="_blank" + >{{ $options.i18n.helpLinkText }}</a + > + </p> + </gl-popover> +</template> diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue new file mode 100644 index 00000000000..bf44ff70562 --- /dev/null +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -0,0 +1,163 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import Tracking from '~/tracking'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSearchBoxByType, + }, + mixins: [Tracking.mixin()], + apollo: { + currentUser: { + query: searchNamespacesWhereUserCanCreateProjectsQuery, + variables() { + return { + search: this.search, + }; + }, + skip() { + return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + inject: [ + 'namespaceFullPath', + 'namespaceId', + 'rootUrl', + 'trackLabel', + 'userNamespaceFullPath', + 'userNamespaceId', + ], + data() { + return { + currentUser: {}, + groupToFilterBy: undefined, + search: '', + selectedNamespace: this.namespaceId + ? { + id: this.namespaceId, + fullPath: this.namespaceFullPath, + } + : { + id: this.userNamespaceId, + fullPath: this.userNamespaceFullPath, + }, + }; + }, + computed: { + userGroups() { + return this.currentUser.groups?.nodes || []; + }, + userNamespace() { + return this.currentUser.namespace || {}; + }, + filteredGroups() { + return this.groupToFilterBy + ? this.userGroups.filter((group) => + group.fullPath.startsWith(this.groupToFilterBy.fullPath), + ) + : this.userGroups; + }, + hasGroupMatches() { + return this.filteredGroups.length; + }, + hasNamespaceMatches() { + return ( + this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) && + !this.groupToFilterBy + ); + }, + hasNoMatches() { + return !this.hasGroupMatches && !this.hasNamespaceMatches; + }, + }, + created() { + eventHub.$on('select-template', this.handleSelectTemplate); + }, + beforeDestroy() { + eventHub.$off('select-template', this.handleSelectTemplate); + }, + methods: { + focusInput() { + this.$refs.search.focusInput(); + }, + handleSelectTemplate(groupId) { + this.groupToFilterBy = this.userGroups.find( + (group) => getIdFromGraphQLId(group.id) === groupId, + ); + if (this.groupToFilterBy) { + this.setNamespace(this.groupToFilterBy); + } + }, + setNamespace({ id, fullPath }) { + this.selectedNamespace = { + id: getIdFromGraphQLId(id), + fullPath, + }; + }, + }, +}; +</script> + +<template> + <gl-button-group class="input-lg"> + <gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button> + <gl-dropdown + :text="selectedNamespace.fullPath" + toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" + data-qa-selector="select_namespace_dropdown" + @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" + @shown="focusInput" + > + <gl-search-box-by-type + ref="search" + v-model.trim="search" + data-qa-selector="select_namespace_dropdown_search_field" + /> + <gl-loading-icon v-if="$apollo.queries.currentUser.loading" /> + <template v-else> + <template v-if="hasGroupMatches"> + <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="group of filteredGroups" + :key="group.id" + @click="setNamespace(group)" + > + {{ group.fullPath }} + </gl-dropdown-item> + </template> + <template v-if="hasNamespaceMatches"> + <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> + <gl-dropdown-item @click="setNamespace(userNamespace)"> + {{ userNamespace.fullPath }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-if="hasNoMatches">{{ __('No matches found') }}</gl-dropdown-text> + </template> + </gl-dropdown> + + <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/projects/new/event_hub.js b/app/assets/javascripts/projects/new/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/projects/new/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js new file mode 100644 index 00000000000..572d3276e4f --- /dev/null +++ b/app/assets/javascripts/projects/new/index.js @@ -0,0 +1,66 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NewProjectCreationApp from './components/app.vue'; +import NewProjectUrlSelect from './components/new_project_url_select.vue'; + +export function initNewProjectCreation() { + const el = document.querySelector('.js-new-project-creation'); + + const { + pushToCreateProjectCommand, + workingWithProjectsHelpPath, + newProjectGuidelines, + hasErrors, + isCiCdAvailable, + } = el.dataset; + + const props = { + hasErrors: parseBoolean(hasErrors), + isCiCdAvailable: parseBoolean(isCiCdAvailable), + newProjectGuidelines, + }; + + const provide = { + workingWithProjectsHelpPath, + pushToCreateProjectCommand, + }; + + return new Vue({ + el, + provide, + render(h) { + return h(NewProjectCreationApp, { props }); + }, + }); +} + +export function initNewProjectUrlSelect() { + const elements = document.querySelectorAll('.js-vue-new-project-url-select'); + + if (!elements.length) { + return; + } + + Vue.use(VueApollo); + + elements.forEach( + (el) => + new Vue({ + el, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + }), + provide: { + namespaceFullPath: el.dataset.namespaceFullPath, + namespaceId: el.dataset.namespaceId, + rootUrl: el.dataset.rootUrl, + trackLabel: el.dataset.trackLabel, + userNamespaceFullPath: el.dataset.userNamespaceFullPath, + userNamespaceId: el.dataset.userNamespaceId, + }, + render: (createElement) => createElement(NewProjectUrlSelect), + }), + ); +} diff --git a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql new file mode 100644 index 00000000000..e16fe5dde49 --- /dev/null +++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql @@ -0,0 +1,14 @@ +query searchNamespacesWhereUserCanCreateProjects($search: String) { + currentUser { + groups(permissionScope: CREATE_PROJECTS, search: $search) { + nodes { + id + fullPath + } + } + namespace { + id + fullPath + } + } +} diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index ebd20583a1c..b350db0c838 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,5 +1,7 @@ import $ from 'jquery'; +import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; +import axios from '../lib/utils/axios_utils'; import { convertToTitleCase, humanize, @@ -9,6 +11,23 @@ import { let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; +const invalidInputClass = 'gl-field-error-outline'; + +const validateImportCredentials = (url, user, password) => { + const endpoint = `${gon.relative_url_root}/import/url/validate`; + return axios + .post(endpoint, { + url, + user, + password, + }) + .then(({ data }) => data) + .catch(() => ({ + // intentionally reporting success in case of validation error + // we do not want to block users from trying import in case of validation exception + success: true, + })); +}; const onProjectNameChange = ($projectNameInput, $projectPathInput) => { const slug = slugify(convertUnicodeToAscii($projectNameInput.val())); @@ -85,7 +104,10 @@ const bindHowToImport = () => { const bindEvents = () => { const $newProjectForm = $('#new_project'); const $projectImportUrl = $('#project_import_url'); - const $projectImportUrlWarning = $('.js-import-url-warning'); + const $projectImportUrlUser = $('#project_import_url_user'); + const $projectImportUrlPassword = $('#project_import_url_password'); + const $projectImportUrlError = $('.js-import-url-error'); + const $projectImportForm = $('.project-import form'); const $projectPath = $('.tab-pane.active #project_path'); const $useTemplateBtn = $('.template-button > input'); const $projectFieldsForm = $('.project-fields-form'); @@ -139,12 +161,15 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - function updateUrlPathWarningVisibility() { - const url = $projectImportUrl.val(); - const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/; - const isUrlValid = URL_PATTERN.test(url); - $projectImportUrlWarning.toggleClass('hide', isUrlValid); - } + const updateUrlPathWarningVisibility = debounce(async () => { + const { success: isUrlValid } = await validateImportCredentials( + $projectImportUrl.val(), + $projectImportUrlUser.val(), + $projectImportUrlPassword.val(), + ); + $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid); + $projectImportUrlError.toggleClass('hide', isUrlValid); + }, 500); let isProjectImportUrlDirty = false; $projectImportUrl.on('blur', () => { @@ -153,9 +178,22 @@ const bindEvents = () => { }); $projectImportUrl.on('keyup', () => { deriveProjectPathFromUrl($projectImportUrl); - // defer error message till first input blur - if (isProjectImportUrlDirty) { - updateUrlPathWarningVisibility(); + }); + + [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => { + $f.on('input', () => { + if (isProjectImportUrlDirty) { + updateUrlPathWarningVisibility(); + } + }); + }); + + $projectImportForm.on('submit', (e) => { + const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`); + if ($invalidFields.length > 0) { + $invalidFields[0].focus(); + e.preventDefault(); + e.stopPropagation(); } }); diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index a5e53ee3927..7fb7a416dca 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -2,8 +2,8 @@ import { escape, find, countBy } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; import { n__, s__, __, sprintf } from '~/locale'; +import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api'; import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; export default class AccessDropdown { @@ -16,9 +16,6 @@ export default class AccessDropdown { 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([]); @@ -318,9 +315,9 @@ export default class AccessDropdown { getData(query, callback) { if (this.hasLicense) { Promise.all([ - this.getDeployKeys(query), - this.getUsers(query), - this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(), + getDeployKeys(query), + getUsers(query), + this.groupsData ? Promise.resolve(this.groupsData) : getGroups(), ]) .then(([deployKeysResponse, usersResponse, groupsResponse]) => { this.groupsData = groupsResponse; @@ -332,7 +329,7 @@ export default class AccessDropdown { createFlash({ message: __('Failed to load groups, users and deploy keys.') }); }); } else { - this.getDeployKeys(query) + getDeployKeys(query) .then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data))) .catch(() => createFlash({ message: __('Failed to load deploy keys.') })); } @@ -473,46 +470,6 @@ export default class AccessDropdown { 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) { - 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, - }, - }); - } - - buildUrl(urlRoot, url) { - let newUrl; - if (urlRoot != null) { - newUrl = urlRoot.replace(/\/$/, '') + url; - } - return newUrl; - } - renderRow(item) { let criteria = {}; let groupRowEl; diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js new file mode 100644 index 00000000000..10f6c28a7bf --- /dev/null +++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js @@ -0,0 +1,45 @@ +import axios from '~/lib/utils/axios_utils'; + +const USERS_PATH = '/-/autocomplete/users.json'; +const GROUPS_PATH = '/-/autocomplete/project_groups.json'; +const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json'; + +const buildUrl = (urlRoot, url) => { + let newUrl; + if (urlRoot != null) { + newUrl = urlRoot.replace(/\/$/, '') + url; + } + return newUrl; +}; + +export const getUsers = (query) => { + return axios.get(buildUrl(gon.relative_url_root || '', USERS_PATH), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); +}; + +export const getGroups = () => { + return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), { + params: { + project_id: gon.current_project_id, + }, + }); +}; + +export const getDeployKeys = (query) => { + return axios.get(buildUrl(gon.relative_url_root || '', DEPLOY_KEYS_PATH), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue new file mode 100644 index 00000000000..9823b0229a0 --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -0,0 +1,409 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlSearchBoxByType, + GlAvatar, + GlSprintf, +} from '@gitlab/ui'; +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'; +import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants'; + +export const i18n = { + selectUsers: s__('ProtectedEnvironment|Select users'), + rolesSectionHeader: s__('AccessDropdown|Roles'), + groupsSectionHeader: s__('AccessDropdown|Groups'), + usersSectionHeader: s__('AccessDropdown|Users'), + deployKeysSectionHeader: s__('AccessDropdown|Deploy Keys'), + ownedBy: __('Owned by %{image_tag}'), +}; + +export default { + i18n, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlSearchBoxByType, + GlAvatar, + GlSprintf, + }, + props: { + accessLevelsData: { + type: Array, + required: true, + }, + accessLevel: { + required: true, + type: String, + }, + hasLicense: { + required: false, + 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: [], + roles: [], + deployKeys: [], + selected: { + [LEVEL_TYPES.GROUP]: [], + [LEVEL_TYPES.USER]: [], + [LEVEL_TYPES.ROLE]: [], + [LEVEL_TYPES.DEPLOY_KEY]: [], + }, + }; + }, + computed: { + preselected() { + return groupBy(this.preselectedItems, 'type'); + }, + showDeployKeys() { + return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length; + }, + toggleLabel() { + const counts = Object.entries(this.selected).reduce((acc, [key, value]) => { + acc[key] = value.length; + return acc; + }, {}); + + const isOnlyRoleSelected = + counts[LEVEL_TYPES.ROLE] === 1 && + [counts[LEVEL_TYPES.USER], counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.DEPLOY_KEY]].every( + (count) => count === 0, + ); + + if (isOnlyRoleSelected) { + return this.selected[LEVEL_TYPES.ROLE][0].text; + } + + const labelPieces = []; + + 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(', ') || this.label; + }, + toggleClass() { + 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: { + query: debounce(function debouncedSearch() { + return this.getData(); + }, 500), + }, + created() { + this.getData({ initial: true }); + }, + methods: { + focusInput() { + this.$refs.search.focusInput(); + }, + getData({ initial = false } = {}) { + this.initialLoading = initial; + this.loading = true; + + if (this.hasLicense) { + Promise.all([ + getDeployKeys(this.query), + getUsers(this.query), + this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), + ]) + .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); + this.setSelected({ initial }); + }) + .catch(() => createFlash({ message: __('Failed to load deploy keys.') })) + .finally(() => { + this.initialLoading = false; + this.loading = false; + }); + } + }, + consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) { + // This re-assignment is intentional as level.type property is being used 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 + this.roles = this.accessLevelsData.map((role) => ({ ...role, type: LEVEL_TYPES.ROLE })); + + if (this.hasLicense) { + this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP })); + this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({ + id, + name, + username, + avatar_url, + type: LEVEL_TYPES.USER, + })); + } + + this.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, + }; + }); + }, + 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.findIndex(({ id }) => id === item.id); + if (itemIndex > -1) { + arr.splice(itemIndex, 1); + } else arr.push(item); + }, + isSelected(item) { + return this.selected[item.type].some((selected) => selected.id === item.id); + }, + emitUpdate() { + this.$emit('select', this.selection); + }, + onHide() { + this.$emit('hidden', this.selection); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :disabled="disabled || initialLoading" + :text="toggleLabel" + 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" :is-loading="loading" /> + </template> + <template v-if="roles.length"> + <gl-dropdown-section-header>{{ + $options.i18n.rolesSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="role in roles" + :key="`${role.id}${role.text}`" + data-testid="role-dropdown-item" + is-check-item + :is-checked="isSelected(role)" + @click.native.capture.stop="onItemClick(role)" + > + {{ role.text }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="groups.length || users.length || showDeployKeys" /> + </template> + + <template v-if="groups.length"> + <gl-dropdown-section-header>{{ + $options.i18n.groupsSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="group in groups" + :key="`${group.id}${group.name}`" + fingerprint + data-testid="group-dropdown-item" + :avatar-url="group.avatar_url" + is-check-item + :is-checked="isSelected(group)" + @click.native.capture.stop="onItemClick(group)" + > + {{ group.name }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="users.length || showDeployKeys" /> + </template> + + <template v-if="users.length"> + <gl-dropdown-section-header>{{ + $options.i18n.usersSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="user in users" + :key="`${user.id}${user.username}`" + data-testid="user-dropdown-item" + :avatar-url="user.avatar_url" + :secondary-text="user.username" + is-check-item + :is-checked="isSelected(user)" + @click.native.capture.stop="onItemClick(user)" + > + {{ user.name }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="showDeployKeys" /> + </template> + + <template v-if="showDeployKeys"> + <gl-dropdown-section-header>{{ + $options.i18n.deployKeysSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="key in deployKeys" + :key="`${key.id}${key.fingerprint}`" + data-testid="deploy_key-dropdown-item" + is-check-item + :is-checked="isSelected(key)" + class="gl-text-truncate" + @click.native.capture.stop="onItemClick(key)" + > + <div class="gl-text-truncate gl-font-weight-bold">{{ key.title }}</div> + <div class="gl-text-gray-700 gl-text-truncate"> + <gl-sprintf :message="$options.i18n.ownedBy"> + <template #image_tag> + <gl-avatar :src="key.avatar_url" :size="24" /> + </template> </gl-sprintf + >{{ key.fullname }} ({{ key.username }}) + </div> + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js new file mode 100644 index 00000000000..11272652b63 --- /dev/null +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; +import Vue from 'vue'; +import AccessDropdown from './components/access_dropdown.vue'; + +export const initAccessDropdown = (el, options) => { + if (!el) { + return false; + } + + 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, + render(createElement) { + const vm = this; + return createElement(AccessDropdown, { + props: { + accessLevel, + accessLevelsData: accessLevelsData.roles, + preselectedItems: preselected, + label, + disabled, + }, + on: { + select(selected) { + vm.$emit('select', selected); + }, + }, + }); + }, + }); +}; |