diff options
Diffstat (limited to 'app/assets/javascripts/groups')
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); + }, + }, + }); + }, + }); +}; |