Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/groups')
-rw-r--r--app/assets/javascripts/groups/components/app.vue42
-rw-r--r--app/assets/javascripts/groups/components/empty_state.vue91
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue4
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue279
-rw-r--r--app/assets/javascripts/groups/components/groups.vue7
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue4
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue4
-rw-r--r--app/assets/javascripts/groups/create_edit_form.js29
-rw-r--r--app/assets/javascripts/groups/index.js25
-rw-r--r--app/assets/javascripts/groups/settings/api/access_dropdown_api.js16
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue194
-rw-r--r--app/assets/javascripts/groups/settings/constants.js3
-rw-r--r--app/assets/javascripts/groups/settings/init_access_dropdown.js36
14 files changed, 719 insertions, 17 deletions
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index e3147065d5c..cd5521c599e 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,19 +1,26 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import createFlash from '~/flash';
-import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
+import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
import eventHub from '../event_hub';
-import groupsComponent from './groups.vue';
+import GroupsComponent from './groups.vue';
+import EmptyState from './empty_state.vue';
export default {
components: {
- groupsComponent,
+ GroupsComponent,
GlModal,
GlLoadingIcon,
+ EmptyState,
+ },
+ inject: {
+ renderEmptyState: {
+ default: false,
+ },
},
props: {
action: {
@@ -47,13 +54,14 @@ export default {
searchEmptyMessage: '',
targetGroup: null,
targetParentGroup: null,
+ showEmptyState: false,
};
},
computed: {
primaryProps() {
return {
text: __('Leave group'),
- attributes: [{ variant: 'warning' }, { category: 'primary' }],
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
};
},
cancelProps() {
@@ -75,6 +83,9 @@ export default {
pageInfo() {
return this.store.getPaginationInfo();
},
+ filterGroupsBy() {
+ return getParameterByName('filter') || null;
+ },
},
created() {
this.searchEmptyMessage = this.hideProjects
@@ -128,19 +139,18 @@ export default {
const page = getParameterByName('page') || null;
const sortBy = getParameterByName('sort') || null;
const archived = getParameterByName('archived') || null;
- const filterGroupsBy = getParameterByName('filter') || null;
this.isLoading = true;
return this.fetchGroups({
page,
- filterGroupsBy,
+ filterGroupsBy: this.filterGroupsBy,
sortBy,
archived,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
- this.updateGroups(res, Boolean(filterGroupsBy));
+ this.updateGroups(res, Boolean(this.filterGroupsBy));
});
},
fetchPage({ page, filterGroupsBy, sortBy, archived }) {
@@ -212,7 +222,7 @@ export default {
this.targetGroup.isBeingRemoved = false;
});
},
- showEmptyState() {
+ showLegacyEmptyState() {
const { containerEl } = this;
const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS);
const emptyStateEl = containerEl.querySelector('.empty-state');
@@ -230,7 +240,12 @@ export default {
},
updateGroups(groups, fromSearch) {
const hasGroups = groups && groups.length > 0;
- this.isSearchEmpty = !hasGroups;
+
+ if (this.renderEmptyState) {
+ this.isSearchEmpty = this.filterGroupsBy !== null && !hasGroups;
+ } else {
+ this.isSearchEmpty = !hasGroups;
+ }
if (fromSearch) {
this.store.setSearchedGroups(groups);
@@ -239,7 +254,11 @@ export default {
}
if (this.action && !hasGroups && !fromSearch) {
- this.showEmptyState();
+ if (this.renderEmptyState) {
+ this.showEmptyState = true;
+ } else {
+ this.showLegacyEmptyState();
+ }
}
},
},
@@ -251,7 +270,7 @@ export default {
<gl-loading-icon
v-if="isLoading"
:label="s__('GroupsTree|Loading groups')"
- size="md"
+ size="lg"
class="loading-animation prepend-top-20"
/>
<groups-component
@@ -262,6 +281,7 @@ export default {
:page-info="pageInfo"
:action="action"
/>
+ <empty-state v-if="showEmptyState" />
<gl-modal
modal-id="leave-group-modal"
:visible="isModalVisible"
diff --git a/app/assets/javascripts/groups/components/empty_state.vue b/app/assets/javascripts/groups/components/empty_state.vue
new file mode 100644
index 00000000000..4219b52737d
--- /dev/null
+++ b/app/assets/javascripts/groups/components/empty_state.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlLink, GlEmptyState } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+
+export default {
+ components: { GlLink, GlEmptyState },
+ i18n: {
+ withLinks: {
+ subgroup: {
+ title: s__('GroupsEmptyState|Create new subgroup'),
+ description: s__(
+ 'GroupsEmptyState|Groups are the best way to manage multiple projects and members.',
+ ),
+ },
+ project: {
+ title: s__('GroupsEmptyState|Create new project'),
+ description: s__(
+ 'GroupsEmptyState|Projects are where you can store your code, access issues, wiki, and other features of Gitlab.',
+ ),
+ },
+ },
+ withoutLinks: {
+ title: s__('GroupsEmptyState|No subgroups or projects.'),
+ description: s__(
+ 'GroupsEmptyState|You do not have necessary permissions to create a subgroup or project in this group. Please contact an owner of this group to create a new subgroup or project.',
+ ),
+ },
+ },
+ linkClasses: [
+ 'gl-border',
+ 'gl-text-decoration-none!',
+ 'gl-rounded-base',
+ 'gl-p-7',
+ 'gl-display-flex',
+ 'gl-h-full',
+ 'gl-align-items-center',
+ 'gl-text-purple-600',
+ 'gl-hover-bg-gray-50',
+ ],
+ inject: [
+ 'newSubgroupPath',
+ 'newProjectPath',
+ 'newSubgroupIllustration',
+ 'newProjectIllustration',
+ 'emptySubgroupIllustration',
+ 'canCreateSubgroups',
+ 'canCreateProjects',
+ ],
+};
+</script>
+
+<template>
+ <div v-if="canCreateSubgroups || canCreateProjects" class="gl-mt-5">
+ <div class="gl-display-flex gl-mx-n3 gl-my-n3 gl-flex-wrap">
+ <div v-if="canCreateSubgroups" class="gl-p-3 gl-w-full gl-sm-w-half">
+ <gl-link :href="newSubgroupPath" :class="$options.linkClasses">
+ <div class="svg-content gl-w-15 gl-flex-shrink-0 gl-mr-5">
+ <img :src="newSubgroupIllustration" :alt="$options.i18n.withLinks.subgroup.title" />
+ </div>
+ <div>
+ <h4 class="gl-reset-color">{{ $options.i18n.withLinks.subgroup.title }}</h4>
+ <p class="gl-text-body">
+ {{ $options.i18n.withLinks.subgroup.description }}
+ </p>
+ </div>
+ </gl-link>
+ </div>
+ <div v-if="canCreateProjects" class="gl-p-3 gl-w-full gl-sm-w-half">
+ <gl-link :href="newProjectPath" :class="$options.linkClasses">
+ <div class="svg-content gl-w-13 gl-flex-shrink-0 gl-mr-5">
+ <img :src="newProjectIllustration" :alt="$options.i18n.withLinks.project.title" />
+ </div>
+ <div>
+ <h4 class="gl-reset-color">{{ $options.i18n.withLinks.project.title }}</h4>
+ <p class="gl-text-body">
+ {{ $options.i18n.withLinks.project.description }}
+ </p>
+ </div>
+ </gl-link>
+ </div>
+ </div>
+ </div>
+ <gl-empty-state
+ v-else
+ class="gl-mt-5"
+ :title="$options.i18n.withoutLinks.title"
+ :svg-path="emptySubgroupIllustration"
+ :description="$options.i18n.withoutLinks.description"
+ />
+</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 042d818338a..96162c32d52 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -39,7 +39,7 @@ export default {
</script>
<template>
- <ul class="groups-list group-list-tree">
+ <ul class="groups-list group-list-tree gl-display-flex gl-flex-direction-column gl-m-0">
<group-item
v-for="(group, index) in groups"
:key="index"
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 4f21f68fa65..2241d57f96f 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -157,7 +157,9 @@ export default {
</a>
<div class="group-text-container d-flex flex-fill align-items-center">
<div class="group-text flex-grow-1 flex-shrink-1">
- <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-wrap title namespace-title gl-font-weight-bold gl-mr-3"
+ >
<a
v-gl-tooltip.bottom
data-testid="group-name"
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
new file mode 100644
index 00000000000..f9bd8701199
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -0,0 +1,279 @@
+<script>
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlLink,
+ GlAlert,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { s__, __ } from '~/locale';
+import { getGroupPathAvailability } from '~/rest_api';
+import { createAlert } from '~/flash';
+import { slugify } from '~/lib/utils/text_utility';
+import axios from '~/lib/utils/axios_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+const DEBOUNCE_DURATION = 1000;
+
+export default {
+ i18n: {
+ inputs: {
+ name: {
+ label: s__('Groups|Group name'),
+ placeholder: __('My awesome group'),
+ description: s__(
+ 'Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.',
+ ),
+ invalidFeedback: s__('Groups|Enter a descriptive name for your group.'),
+ },
+ path: {
+ label: s__('Groups|Group URL'),
+ placeholder: __('my-awesome-group'),
+ invalidFeedbackInvalidPattern: s__(
+ 'GroupSettings|Choose a group path that does not start with a dash or end with a period. It can also contain alphanumeric characters and underscores.',
+ ),
+ invalidFeedbackPathUnavailable: s__(
+ 'Groups|Group path is unavailable. Path has been replaced with a suggested available path.',
+ ),
+ validFeedback: s__('Groups|Group path is available.'),
+ },
+ groupId: {
+ label: s__('Groups|Group ID'),
+ },
+ },
+ apiLoadingMessage: s__('Groups|Checking group URL availability...'),
+ apiErrorMessage: __(
+ 'An error occurred while checking group path. Please refresh and try again.',
+ ),
+ changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'),
+ learnMore: s__('Groups|Learn more'),
+ },
+ nameInputSize: { md: 'lg' },
+ changingGroupPathHelpPagePath: helpPagePath('user/group/index', {
+ anchor: 'change-a-groups-path',
+ }),
+ mattermostDataBindName: 'create_chat_team',
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlLink,
+ GlAlert,
+ },
+ inject: ['fields', 'basePath', 'mattermostEnabled'],
+ data() {
+ return {
+ name: this.fields.name.value,
+ path: this.fields.path.value,
+ hasPathBeenManuallySet: false,
+ apiSuggestedPath: '',
+ apiLoading: false,
+ nameFeedbackState: null,
+ pathFeedbackState: null,
+ pathInvalidFeedback: null,
+ activeApiRequestAbortController: null,
+ };
+ },
+ computed: {
+ computedPath() {
+ return this.apiSuggestedPath || this.path;
+ },
+ pathDescription() {
+ return this.apiLoading ? this.$options.i18n.apiLoadingMessage : '';
+ },
+ isEditingGroup() {
+ return this.fields.groupId.value !== '';
+ },
+ },
+ watch: {
+ name: [
+ function updatePath(newName) {
+ if (this.isEditingGroup || this.hasPathBeenManuallySet) return;
+
+ this.nameFeedbackState = null;
+ this.pathFeedbackState = null;
+ this.apiSuggestedPath = '';
+ this.path = slugify(newName);
+ },
+ debounce(async function updatePathWithSuggestions() {
+ if (this.isEditingGroup || this.hasPathBeenManuallySet) return;
+
+ try {
+ const { suggests } = await this.checkPathAvailability();
+
+ const [suggestedPath] = suggests;
+
+ this.apiSuggestedPath = suggestedPath;
+ } catch (error) {
+ // Do nothing, error handled in `checkPathAvailability`
+ }
+ }, DEBOUNCE_DURATION),
+ ],
+ },
+ methods: {
+ async checkPathAvailability() {
+ if (!this.path) return Promise.reject();
+
+ this.apiLoading = true;
+
+ if (this.activeApiRequestAbortController !== null) {
+ this.activeApiRequestAbortController.abort();
+ }
+
+ this.activeApiRequestAbortController = new AbortController();
+
+ try {
+ const {
+ data: { exists, suggests },
+ } = await getGroupPathAvailability(this.path, this.fields.parentId?.value, {
+ signal: this.activeApiRequestAbortController.signal,
+ });
+
+ if (exists) {
+ if (suggests.length) {
+ return Promise.resolve({ exists, suggests });
+ }
+
+ createAlert({
+ message: this.$options.i18n.apiErrorMessage,
+ });
+
+ return Promise.reject();
+ }
+
+ return Promise.resolve({ exists, suggests });
+ } catch (error) {
+ if (!axios.isCancel(error)) {
+ createAlert({
+ message: this.$options.i18n.apiErrorMessage,
+ });
+ }
+
+ return Promise.reject();
+ } finally {
+ this.apiLoading = false;
+ }
+ },
+ handlePathInput(value) {
+ this.pathFeedbackState = null;
+ this.apiSuggestedPath = '';
+ this.hasPathBeenManuallySet = true;
+ this.path = value;
+ this.debouncedValidatePath();
+ },
+ debouncedValidatePath: debounce(async function validatePath() {
+ if (this.isEditingGroup && this.path === this.fields.path.value) return;
+
+ try {
+ const {
+ exists,
+ suggests: [suggestedPath],
+ } = await this.checkPathAvailability();
+
+ if (exists) {
+ this.apiSuggestedPath = suggestedPath;
+ this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackPathUnavailable;
+ this.pathFeedbackState = false;
+ } else {
+ this.pathFeedbackState = true;
+ }
+ } catch (error) {
+ // Do nothing, error handled in `checkPathAvailability`
+ }
+ }, DEBOUNCE_DURATION),
+ handleInvalidName(event) {
+ event.preventDefault();
+
+ this.nameFeedbackState = false;
+ },
+ handleInvalidPath(event) {
+ event.preventDefault();
+
+ this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackInvalidPattern;
+ this.pathFeedbackState = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input
+ :id="fields.parentId.id"
+ type="hidden"
+ :name="fields.parentId.name"
+ :value="fields.parentId.value"
+ />
+ <gl-form-group
+ :label="$options.i18n.inputs.name.label"
+ :description="$options.i18n.inputs.name.description"
+ :label-for="fields.name.id"
+ :invalid-feedback="$options.i18n.inputs.name.invalidFeedback"
+ :state="nameFeedbackState"
+ >
+ <gl-form-input
+ :id="fields.name.id"
+ v-model="name"
+ class="gl-field-error-ignore"
+ required
+ :name="fields.name.name"
+ :placeholder="$options.i18n.inputs.name.placeholder"
+ data-qa-selector="group_name_field"
+ :size="$options.nameInputSize"
+ :state="nameFeedbackState"
+ @invalid="handleInvalidName"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.inputs.path.label"
+ :label-for="fields.path.id"
+ :description="pathDescription"
+ :state="pathFeedbackState"
+ :valid-feedback="$options.i18n.inputs.path.validFeedback"
+ :invalid-feedback="pathInvalidFeedback"
+ >
+ <gl-form-input-group>
+ <template #prepend>
+ <gl-input-group-text class="group-root-path">{{ basePath }}</gl-input-group-text>
+ </template>
+ <gl-form-input
+ :id="fields.path.id"
+ class="gl-field-error-ignore"
+ :name="fields.path.name"
+ :value="computedPath"
+ :placeholder="$options.i18n.inputs.path.placeholder"
+ :maxlength="fields.path.maxLength"
+ :pattern="fields.path.pattern"
+ :state="pathFeedbackState"
+ :size="$options.nameInputSize"
+ required
+ data-qa-selector="group_path_field"
+ :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null"
+ @input="handlePathInput"
+ @invalid="handleInvalidPath"
+ />
+ </gl-form-input-group>
+ </gl-form-group>
+ <template v-if="isEditingGroup">
+ <gl-alert class="gl-mb-5" :dismissible="false" variant="warning">
+ {{ $options.i18n.changingUrlWarningMessage }}
+ <gl-link :href="$options.changingGroupPathHelpPagePath"
+ >{{ $options.i18n.learnMore }}
+ </gl-link>
+ </gl-alert>
+ <gl-form-group :label="$options.i18n.inputs.groupId.label" :label-for="fields.groupId.id">
+ <gl-form-input
+ :id="fields.groupId.id"
+ :value="fields.groupId.value"
+ :name="fields.groupId.name"
+ size="sm"
+ readonly
+ />
+ </gl-form-group>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 313c8dadd1f..5706df0dd1b 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -43,7 +43,12 @@ export default {
<template>
<div class="groups-list-tree-container qa-groups-list-tree-container">
- <div v-if="searchEmpty" class="has-no-search-results">{{ searchEmptyMessage }}</div>
+ <div
+ v-if="searchEmpty"
+ class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5"
+ >
+ {{ searchEmptyMessage }}
+ </div>
<template v-else>
<group-folder :groups="groups" :action="action" />
<pagination-links
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index a51edd385dd..ef82e6d693a 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -14,14 +14,14 @@ export default {
},
computed: {
iconClass() {
- return this.isGroupOpen ? 'angle-down' : 'angle-right';
+ return this.isGroupOpen ? 'chevron-down' : 'chevron-right';
},
},
};
</script>
<template>
- <span class="folder-caret gl-mr-2">
+ <span class="folder-caret gl-display-inline-block gl-text-secondary gl-w-5 gl-mr-2">
<gl-icon :size="12" :name="iconClass" />
</span>
</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
index 7821e604700..da4173993c5 100644
--- a/app/assets/javascripts/groups/components/item_type_icon.vue
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -24,5 +24,7 @@ export default {
</script>
<template>
- <span class="item-type-icon"> <gl-icon :name="iconClass" /> </span>
+ <span class="item-type-icon gl-display-inline-block gl-text-secondary">
+ <gl-icon :name="iconClass" />
+ </span>
</template>
diff --git a/app/assets/javascripts/groups/create_edit_form.js b/app/assets/javascripts/groups/create_edit_form.js
new file mode 100644
index 00000000000..8ca0e6077e9
--- /dev/null
+++ b/app/assets/javascripts/groups/create_edit_form.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import { parseRailsFormFields } from '~/lib/utils/forms';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import GroupNameAndPath from './components/group_name_and_path.vue';
+
+export const initGroupNameAndPath = () => {
+ const elements = document.querySelectorAll('.js-group-name-and-path');
+
+ if (!elements.length) {
+ return;
+ }
+
+ elements.forEach((element) => {
+ const fields = parseRailsFormFields(element);
+ const { basePath, mattermostEnabled } = element.dataset;
+
+ return new Vue({
+ el: element,
+ provide: {
+ fields,
+ basePath,
+ mattermostEnabled: parseBoolean(mattermostEnabled),
+ },
+ render(h) {
+ return h(GroupNameAndPath);
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index c34810954a3..dfcee80aec7 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -44,6 +44,31 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
components: {
groupsApp,
},
+ provide() {
+ const {
+ dataset: {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ renderEmptyState,
+ canCreateSubgroups,
+ canCreateProjects,
+ },
+ } = this.$options.el;
+
+ return {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ renderEmptyState: parseBoolean(renderEmptyState),
+ canCreateSubgroups: parseBoolean(canCreateSubgroups),
+ canCreateProjects: parseBoolean(canCreateProjects),
+ };
+ },
data() {
const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects);
diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
new file mode 100644
index 00000000000..5560d10d179
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
@@ -0,0 +1,16 @@
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+
+const GROUP_SUBGROUPS_PATH = '/-/autocomplete/group_subgroups.json';
+
+const buildUrl = (urlRoot, url) => {
+ return joinPaths(urlRoot, url);
+};
+
+export const getSubGroups = () => {
+ return axios.get(buildUrl(gon.relative_url_root || '', GROUP_SUBGROUPS_PATH), {
+ params: {
+ group_id: gon.current_group_id,
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
new file mode 100644
index 00000000000..b8a269de98a
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -0,0 +1,194 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
+import createFlash from '~/flash';
+import { __, s__, n__ } from '~/locale';
+import { getSubGroups } from '../api/access_dropdown_api';
+import { LEVEL_TYPES } from '../constants';
+
+export const i18n = {
+ selectUsers: s__('ProtectedEnvironment|Select groups'),
+ groupsSectionHeader: s__('AccessDropdown|Groups'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ 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: '',
+ groups: [],
+ selected: {
+ [LEVEL_TYPES.GROUP]: [],
+ },
+ };
+ },
+ computed: {
+ preselected() {
+ return groupBy(this.preselectedItems, 'type');
+ },
+ toggleLabel() {
+ const counts = Object.fromEntries(
+ Object.entries(this.selected).map(([key, value]) => [key, value.length]),
+ );
+
+ const labelPieces = [];
+
+ 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.GROUP, 'group_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([this.groups.length ? Promise.resolve({ data: this.groups }) : getSubGroups()])
+ .then(([groupsResponse]) => {
+ this.consolidateData(groupsResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() => createFlash({ message: __('Failed to load groups.') }))
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ }
+ },
+ consolidateData(groupsResponse = []) {
+ if (this.hasLicense) {
+ this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
+ }
+ },
+ setSelected({ initial } = {}) {
+ if (initial) {
+ 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;
+ }
+ },
+ 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="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>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
new file mode 100644
index 00000000000..c91c2a20529
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -0,0 +1,3 @@
+export const LEVEL_TYPES = {
+ GROUP: 'group',
+};
diff --git a/app/assets/javascripts/groups/settings/init_access_dropdown.js b/app/assets/javascripts/groups/settings/init_access_dropdown.js
new file mode 100644
index 00000000000..24419280fc0
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/init_access_dropdown.js
@@ -0,0 +1,36 @@
+import * as Sentry from '@sentry/browser';
+import Vue from 'vue';
+import AccessDropdown from './components/access_dropdown.vue';
+
+export const initAccessDropdown = (el) => {
+ if (!el) {
+ return false;
+ }
+
+ 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: {
+ preselectedItems: preselected,
+ label,
+ disabled,
+ },
+ on: {
+ select(selected) {
+ vm.$emit('select', selected);
+ },
+ },
+ });
+ },
+ });
+};