summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/projects
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-10-20 08:43:02 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-10-20 08:43:02 +0000
commitd9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch)
tree2341ef426af70ad1e289c38036737e04b0aa5007 /app/assets/javascripts/projects
parentd6e514dd13db8947884cd58fe2a9c2a063400a9b (diff)
downloadgitlab-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')
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue124
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue66
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue163
-rw-r--r--app/assets/javascripts/projects/new/event_hub.js3
-rw-r--r--app/assets/javascripts/projects/new/index.js66
-rw-r--r--app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql14
-rw-r--r--app/assets/javascripts/projects/project_new.js58
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js53
-rw-r--r--app/assets/javascripts/projects/settings/api/access_dropdown_api.js45
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue409
-rw-r--r--app/assets/javascripts/projects/settings/init_access_dropdown.js39
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);
+ },
+ },
+ });
+ },
+ });
+};