407 lines
12 KiB
Vue
407 lines
12 KiB
Vue
<script>
|
|
import {
|
|
GlFormGroup,
|
|
GlFormInput,
|
|
GlFormInputGroup,
|
|
GlInputGroupText,
|
|
GlLink,
|
|
GlAlert,
|
|
GlButton,
|
|
GlButtonGroup,
|
|
GlDropdown,
|
|
GlDropdownItem,
|
|
GlDropdownText,
|
|
GlTruncate,
|
|
GlSearchBoxByType,
|
|
} from '@gitlab/ui';
|
|
import { debounce } from 'lodash';
|
|
|
|
import { s__, __ } from '~/locale';
|
|
import { getGroupPathAvailability } from '~/rest_api';
|
|
import { createAlert } from '~/alert';
|
|
import { slugify } from '~/lib/utils/text_utility';
|
|
import axios from '~/lib/utils/axios_utils';
|
|
import { helpPagePath } from '~/helpers/help_page_helper';
|
|
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
|
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
|
|
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
|
|
|
|
import searchGroupsWhereUserCanCreateSubgroups from '../queries/search_groups_where_user_can_create_subgroups.query.graphql';
|
|
|
|
const DEBOUNCE_DURATION = 1000;
|
|
|
|
export default {
|
|
i18n: {
|
|
inputs: {
|
|
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: {
|
|
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.'),
|
|
},
|
|
},
|
|
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'),
|
|
},
|
|
inputSize: { md: 'lg' },
|
|
changingGroupPathHelpPagePath: helpPagePath('user/group/manage', {
|
|
anchor: 'change-a-groups-path',
|
|
}),
|
|
mattermostDataBindName: 'create_chat_team',
|
|
components: {
|
|
GlFormGroup,
|
|
GlFormInput,
|
|
GlFormInputGroup,
|
|
GlInputGroupText,
|
|
GlLink,
|
|
GlAlert,
|
|
GlButton,
|
|
GlButtonGroup,
|
|
GlDropdown,
|
|
GlDropdownItem,
|
|
GlDropdownText,
|
|
GlTruncate,
|
|
GlSearchBoxByType,
|
|
},
|
|
apollo: {
|
|
currentUserGroups: {
|
|
query: searchGroupsWhereUserCanCreateSubgroups,
|
|
variables() {
|
|
return {
|
|
search: this.search,
|
|
};
|
|
},
|
|
update(data) {
|
|
return data.currentUser?.groups?.nodes || [];
|
|
},
|
|
skip() {
|
|
const hasNotEnoughSearchCharacters =
|
|
this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
|
|
|
|
return this.shouldSkipQuery || hasNotEnoughSearchCharacters;
|
|
},
|
|
debounce: DEBOUNCE_DELAY,
|
|
},
|
|
},
|
|
inject: ['fields', 'basePath', 'newSubgroup', '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,
|
|
search: '',
|
|
currentUserGroups: {},
|
|
shouldSkipQuery: true,
|
|
selectedGroup: {
|
|
id: this.fields.parentId.value,
|
|
fullPath: this.fields.parentFullPath.value,
|
|
},
|
|
};
|
|
},
|
|
computed: {
|
|
inputLabels() {
|
|
return {
|
|
name: this.newSubgroup ? s__('Groups|Subgroup name') : s__('Groups|Group name'),
|
|
path: this.newSubgroup ? s__('Groups|Subgroup slug') : s__('Groups|Group URL'),
|
|
subgroupPath: s__('Groups|Subgroup URL'),
|
|
groupId: s__('Groups|Group ID'),
|
|
};
|
|
},
|
|
pathInputSize() {
|
|
return this.newSubgroup ? {} : this.$options.inputSize;
|
|
},
|
|
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.selectedGroup.id || this.fields.parentId.value,
|
|
{ signal: this.activeApiRequestAbortController.signal },
|
|
);
|
|
|
|
this.apiLoading = false;
|
|
|
|
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)) {
|
|
this.apiLoading = false;
|
|
|
|
createAlert({
|
|
message: this.$options.i18n.apiErrorMessage,
|
|
});
|
|
}
|
|
|
|
return Promise.reject();
|
|
}
|
|
},
|
|
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;
|
|
},
|
|
handleDropdownShown() {
|
|
if (this.shouldSkipQuery) {
|
|
this.shouldSkipQuery = false;
|
|
}
|
|
|
|
this.$refs.search.focusInput();
|
|
},
|
|
handleDropdownItemClick({ id, fullPath }) {
|
|
this.selectedGroup = {
|
|
id: getIdFromGraphQLId(id),
|
|
fullPath,
|
|
};
|
|
|
|
this.debouncedValidatePath();
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<input
|
|
:id="fields.parentId.id"
|
|
type="hidden"
|
|
:name="fields.parentId.name"
|
|
:value="selectedGroup.id"
|
|
/>
|
|
<gl-form-group
|
|
:label="inputLabels.name"
|
|
: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 gl-h-auto!"
|
|
required
|
|
:name="fields.name.name"
|
|
:placeholder="$options.i18n.inputs.name.placeholder"
|
|
data-qa-selector="group_name_field"
|
|
:size="$options.inputSize"
|
|
:state="nameFeedbackState"
|
|
@invalid="handleInvalidName"
|
|
/>
|
|
</gl-form-group>
|
|
|
|
<div :class="newSubgroup && 'row gl-mb-3'">
|
|
<gl-form-group v-if="newSubgroup" class="col-sm-6 gl-pr-0" :label="inputLabels.subgroupPath">
|
|
<div class="input-group gl-flex-wrap-nowrap">
|
|
<gl-button-group class="gl-w-full">
|
|
<gl-button class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!" label>
|
|
{{ basePath }}
|
|
</gl-button>
|
|
|
|
<gl-dropdown
|
|
class="js-group-namespace-dropdown gl-flex-grow-1"
|
|
toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
|
|
@shown="handleDropdownShown"
|
|
>
|
|
<template #button-text>
|
|
<gl-truncate
|
|
v-if="selectedGroup.fullPath"
|
|
:text="selectedGroup.fullPath"
|
|
position="start"
|
|
with-tooltip
|
|
/>
|
|
</template>
|
|
|
|
<gl-search-box-by-type
|
|
ref="search"
|
|
v-model.trim="search"
|
|
:is-loading="$apollo.queries.currentUserGroups.loading"
|
|
/>
|
|
|
|
<template v-if="!$apollo.queries.currentUserGroups.loading">
|
|
<template v-if="currentUserGroups.length">
|
|
<gl-dropdown-item
|
|
v-for="group of currentUserGroups"
|
|
:key="group.id"
|
|
data-testid="select_group_dropdown_item"
|
|
@click="handleDropdownItemClick(group)"
|
|
>
|
|
{{ group.fullPath }}
|
|
</gl-dropdown-item>
|
|
</template>
|
|
<gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
|
|
</template>
|
|
</gl-dropdown>
|
|
</gl-button-group>
|
|
|
|
<div class="gl-align-self-center gl-pl-5">
|
|
<span class="gl-display-none gl-md-display-inline">/</span>
|
|
</div>
|
|
</div>
|
|
</gl-form-group>
|
|
|
|
<gl-form-group
|
|
:class="newSubgroup && 'col-sm-6'"
|
|
:label="inputLabels.path"
|
|
:label-for="fields.path.id"
|
|
:description="pathDescription"
|
|
:state="pathFeedbackState"
|
|
:valid-feedback="$options.i18n.inputs.path.validFeedback"
|
|
:invalid-feedback="pathInvalidFeedback"
|
|
>
|
|
<gl-form-input-group>
|
|
<template v-if="!newSubgroup" #prepend>
|
|
<gl-input-group-text class="group-root-path">
|
|
{{ basePath.concat(fields.parentFullPath.value) }}
|
|
</gl-input-group-text>
|
|
</template>
|
|
<gl-form-input
|
|
:id="fields.path.id"
|
|
class="gl-field-error-ignore gl-h-auto!"
|
|
:name="fields.path.name"
|
|
:value="computedPath"
|
|
:placeholder="$options.i18n.inputs.path.placeholder"
|
|
:maxlength="fields.path.maxLength"
|
|
:pattern="fields.path.pattern"
|
|
:state="pathFeedbackState"
|
|
:size="pathInputSize"
|
|
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>
|
|
</div>
|
|
|
|
<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="inputLabels.groupId" :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>
|