Commit c0364439 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '300427-validate-group-names-before-importing' into 'master'

Validate group names before importing

See merge request gitlab-org/gitlab!54927
parents 51f2e31f 82508cf4
......@@ -34,7 +34,7 @@ export default {
</script>
<template>
<div>
<div class="gl-display-flex gl-h-7 gl-align-items-center">
<gl-loading-icon
v-if="mappedStatus.loadingIcon"
:inline="true"
......
......@@ -33,6 +33,10 @@ export default {
type: String,
required: true,
},
groupPathRegex: {
type: RegExp,
required: true,
},
},
data() {
......@@ -165,12 +169,13 @@ export default {
<th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th>
<th class="gl-py-4 import-jobs-cta-col"></th>
</thead>
<tbody>
<tbody class="gl-vertical-align-top">
<template v-for="group in bulkImportSourceGroups.nodes">
<import-table-row
:key="group.id"
:group="group"
:available-namespaces="availableNamespaces"
:group-path-regex="groupPathRegex"
@update-target-namespace="updateTargetNamespace(group.id, $event)"
@update-new-name="updateNewName(group.id, $event)"
@import-group="importGroup(group.id)"
......
<script>
import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui';
import {
GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlIcon,
GlLink,
GlFormInput,
} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import Select2Select from '~/vue_shared/components/select2_select.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
import groupQuery from '../graphql/queries/group.query.graphql';
const DEBOUNCE_INTERVAL = 300;
export default {
components: {
Select2Select,
ImportStatus,
GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlLink,
GlIcon,
GlFormInput,
......@@ -24,82 +37,131 @@ export default {
type: Array,
required: true,
},
groupPathRegex: {
type: RegExp,
required: true,
},
computed: {
isDisabled() {
return this.group.status !== STATUSES.NONE;
},
isFinished() {
return this.group.status === STATUSES.FINISHED;
apollo: {
existingGroup: {
query: groupQuery,
debounce: DEBOUNCE_INTERVAL,
variables() {
return {
fullPath: this.fullPath,
};
},
skip() {
return !this.isNameValid || this.isAlreadyImported;
},
},
},
select2Options() {
const availableNamespacesData = this.availableNamespaces.map((namespace) => ({
id: namespace.full_path,
text: namespace.full_path,
}));
computed: {
importTarget() {
return this.group.import_target;
},
const select2Config = {
data: [{ id: '', text: s__('BulkImport|No parent') }],
};
isInvalid() {
return Boolean(!this.isNameValid || this.existingGroup);
},
if (availableNamespacesData.length) {
select2Config.data.push({
text: s__('BulkImport|Existing groups'),
children: availableNamespacesData,
});
}
isNameValid() {
return this.groupPathRegex.test(this.importTarget.new_name);
},
return select2Config;
isAlreadyImported() {
return this.group.status !== STATUSES.NONE;
},
isFinished() {
return this.group.status === STATUSES.FINISHED;
},
methods: {
getPath(group) {
return `${group.import_target.target_namespace}/${group.import_target.new_name}`;
fullPath() {
return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
},
getFullPath(group) {
return joinPaths(gon.relative_url_root || '/', this.getPath(group));
absolutePath() {
return joinPaths(gon.relative_url_root || '/', this.fullPath);
},
},
};
</script>
<template>
<tr class="gl-border-gray-200 gl-border-0 gl-border-b-1">
<tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid">
<td class="gl-p-4">
<gl-link :href="group.web_url" target="_blank">
<gl-link
:href="group.web_url"
target="_blank"
class="gl-display-flex gl-align-items-center gl-h-7"
>
{{ group.full_path }} <gl-icon name="external-link" />
</gl-link>
</td>
<td class="gl-p-4">
<gl-link v-if="isFinished" :href="getFullPath(group)">{{ getPath(group) }}</gl-link>
<gl-link
v-if="isFinished"
class="gl-display-flex gl-align-items-center gl-h-7"
:href="absolutePath"
>
{{ fullPath }}
</gl-link>
<div
v-else
class="import-entities-target-select gl-display-flex gl-align-items-stretch"
:class="{
disabled: isDisabled,
disabled: isAlreadyImported,
}"
>
<select2-select
:disabled="isDisabled"
:options="select2Options"
:value="group.import_target.target_namespace"
@input="$emit('update-target-namespace', $event)"
/>
<gl-dropdown
:text="importTarget.target_namespace"
:disabled="isAlreadyImported"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1"
>
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
<template v-if="availableNamespaces.length">
<gl-dropdown-divider />
<gl-dropdown-section-header>
{{ s__('BulkImport|Existing groups') }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in availableNamespaces"
:key="ns.full_path"
@click="$emit('update-target-namespace', ns.full_path)"
>
{{ ns.full_path }}
</gl-dropdown-item>
</template>
</gl-dropdown>
<div
class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
/
</div>
<div class="gl-flex-fill-1">
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:disabled="isDisabled"
:value="group.import_target.new_name"
:class="{ 'is-invalid': isInvalid && !isAlreadyImported }"
:disabled="isAlreadyImported"
:value="importTarget.new_name"
@input="$emit('update-new-name', $event)"
/>
<p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
<template v-if="!isNameValid">
{{ __('Please choose a group URL with no special characters.') }}
</template>
<template v-else-if="existingGroup">
{{ s__('BulkImport|Name already exists.') }}
</template>
</p>
</div>
</div>
</td>
<td class="gl-p-4 gl-white-space-nowrap">
......@@ -107,7 +169,8 @@ export default {
</td>
<td class="gl-p-4">
<gl-button
v-if="!isDisabled"
v-if="!isAlreadyImported"
:disabled="isInvalid"
variant="success"
category="secondary"
@click="$emit('import-group')"
......
query group($fullPath: ID!) {
existingGroup: group(fullPath: $fullPath) {
id
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import ImportTable from './components/import_table.vue';
import { createApolloClient } from './graphql/client_factory';
......@@ -17,7 +16,7 @@ export function mountImportGroupsApp(mountElement) {
createBulkImportPath,
jobsPath,
sourceUrl,
canCreateGroup,
groupPathRegex,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
......@@ -38,7 +37,7 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
canCreateGroup: parseBoolean(canCreateGroup),
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
},
});
},
......
@import 'mixins_and_variables_and_functions';
// Fixing double scrollbar issue
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1156 and
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54837
.import-entities-namespace-dropdown.show.dropdown .dropdown-menu {
max-height: initial;
}
.import-jobs-to-col {
width: 39%;
}
......
......@@ -9,4 +9,5 @@
available_namespaces_path: import_available_namespaces_path(format: :json),
create_bulk_import_path: import_bulk_imports_path(format: :json),
jobs_path: realtime_changes_import_bulk_imports_path(format: :json),
source_url: @source_url } }
source_url: @source_url,
group_path_regex: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS } }
......@@ -5093,6 +5093,9 @@ msgstr ""
msgid "BulkImport|Importing the group failed"
msgstr ""
msgid "BulkImport|Name already exists."
msgstr ""
msgid "BulkImport|No parent"
msgstr ""
......
import { GlButton, GlLink, GlFormInput } from '@gitlab/ui';
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { STATUSES } from '~/import_entities/constants';
import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
import Select2Select from '~/vue_shared/components/select2_select.vue';
import groupQuery from '~/import_entities/import_groups/graphql/queries/group.query.graphql';
import { availableNamespacesFixture } from '../graphql/fixtures';
Vue.use(VueApollo);
const getFakeGroup = (status) => ({
web_url: 'https://fake.host/',
full_path: 'fake_group_1',
......@@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({
status,
});
const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group';
const EXISTING_GROUP_PATH = 'existing-path';
describe('import table row', () => {
let wrapper;
let apolloProvider;
let group;
const findByText = (cmp, text) => {
......@@ -26,12 +35,27 @@ describe('import table row', () => {
};
const findImportButton = () => findByText(GlButton, 'Import');
const findNameInput = () => wrapper.find(GlFormInput);
const findNamespaceDropdown = () => wrapper.find(Select2Select);
const findNamespaceDropdown = () => wrapper.find(GlDropdown);
const createComponent = (props) => {
apolloProvider = createMockApollo([
[
groupQuery,
({ fullPath }) => {
const existingGroup =
fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}`
? { id: 1 }
: null;
return Promise.resolve({ data: { existingGroup } });
},
],
]);
wrapper = shallowMount(ImportTableRow, {
apolloProvider,
propsData: {
availableNamespaces: availableNamespacesFixture,
groupPathRegex: /.*/,
...props,
},
});
......@@ -50,7 +74,6 @@ describe('import table row', () => {
it.each`
selector | sourceEvent | payload | event
${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'}
${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'}
${findImportButton} | ${'click'} | ${undefined} | ${'import-group'}
`('invokes $event', ({ selector, sourceEvent, payload, event }) => {
......@@ -58,6 +81,16 @@ describe('import table row', () => {
expect(wrapper.emitted(event)).toBeDefined();
expect(wrapper.emitted(event)[0][0]).toBe(payload);
});
it('emits update-target-namespace when dropdown option is clicked', () => {
const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2);
const dropdownItemText = dropdownItem.text();
dropdownItem.vm.$emit('click');
expect(wrapper.emitted('update-target-namespace')).toBeDefined();
expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText);
});
});
describe('when entity status is NONE', () => {
......@@ -81,12 +114,12 @@ describe('import table row', () => {
availableNamespaces: [],
});
const dropdownData = findNamespaceDropdown().props().options.data;
const noParentOption = dropdownData.find((o) => o.text === 'No parent');
const existingGroupOption = dropdownData.find((o) => o.text === 'Existing groups');
const items = findNamespaceDropdown()
.findAllComponents(GlDropdownItem)
.wrappers.map((w) => w.text());
expect(noParentOption.id).toBe('');
expect(existingGroupOption).toBeUndefined();
expect(items[0]).toBe('No parent');
expect(items).toHaveLength(1);
});
it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => {
......@@ -95,12 +128,12 @@ describe('import table row', () => {
availableNamespaces: availableNamespacesFixture,
});
const dropdownData = findNamespaceDropdown().props().options.data;
const noParentOption = dropdownData.find((o) => o.text === 'No parent');
const existingGroupOption = dropdownData.find((o) => o.text === 'Existing groups');
const [firstItem, ...rest] = findNamespaceDropdown()
.findAllComponents(GlDropdownItem)
.wrappers.map((w) => w.text());
expect(noParentOption.id).toBe('');
expect(existingGroupOption.children).toHaveLength(availableNamespacesFixture.length);
expect(firstItem).toBe('No parent');
expect(rest).toHaveLength(availableNamespacesFixture.length);
});
describe('when entity status is SCHEDULING', () => {
......@@ -137,4 +170,38 @@ describe('import table row', () => {
expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true);
});
});
describe('validations', () => {
it('Reports invalid group name when name is not matching regex', () => {
createComponent({
group: {
...getFakeGroup(STATUSES.NONE),
import_target: {
target_namespace: 'root',
new_name: 'very`bad`name',
},
},
groupPathRegex: /^[a-zA-Z]+$/,
});
expect(wrapper.text()).toContain('Please choose a group URL with no special characters.');
});
it('Reports invalid group name if group already exists', async () => {
createComponent({
group: {
...getFakeGroup(STATUSES.NONE),
import_target: {
target_namespace: EXISTING_GROUP_TARGET_NAMESPACE,
new_name: EXISTING_GROUP_PATH,
},
},
});
jest.runOnlyPendingTimers();
await nextTick();
expect(wrapper.text()).toContain('Name already exists.');
});
});
});
......@@ -43,6 +43,7 @@ describe('import table', () => {
wrapper = shallowMount(ImportTable, {
propsData: {
sourceUrl: 'https://demo.host',
groupPathRegex: /.*/,
},
stubs: {
GlSprintf,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment