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 { ...@@ -34,7 +34,7 @@ export default {
</script> </script>
<template> <template>
<div> <div class="gl-display-flex gl-h-7 gl-align-items-center">
<gl-loading-icon <gl-loading-icon
v-if="mappedStatus.loadingIcon" v-if="mappedStatus.loadingIcon"
:inline="true" :inline="true"
......
...@@ -33,6 +33,10 @@ export default { ...@@ -33,6 +33,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
groupPathRegex: {
type: RegExp,
required: true,
},
}, },
data() { data() {
...@@ -165,12 +169,13 @@ export default { ...@@ -165,12 +169,13 @@ export default {
<th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th>
<th class="gl-py-4 import-jobs-cta-col"></th> <th class="gl-py-4 import-jobs-cta-col"></th>
</thead> </thead>
<tbody> <tbody class="gl-vertical-align-top">
<template v-for="group in bulkImportSourceGroups.nodes"> <template v-for="group in bulkImportSourceGroups.nodes">
<import-table-row <import-table-row
:key="group.id" :key="group.id"
:group="group" :group="group"
:available-namespaces="availableNamespaces" :available-namespaces="availableNamespaces"
:group-path-regex="groupPathRegex"
@update-target-namespace="updateTargetNamespace(group.id, $event)" @update-target-namespace="updateTargetNamespace(group.id, $event)"
@update-new-name="updateNewName(group.id, $event)" @update-new-name="updateNewName(group.id, $event)"
@import-group="importGroup(group.id)" @import-group="importGroup(group.id)"
......
<script> <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 { 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 ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants'; import { STATUSES } from '../../constants';
import groupQuery from '../graphql/queries/group.query.graphql';
const DEBOUNCE_INTERVAL = 300;
export default { export default {
components: { components: {
Select2Select,
ImportStatus, ImportStatus,
GlButton, GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlLink, GlLink,
GlIcon, GlIcon,
GlFormInput, GlFormInput,
...@@ -24,82 +37,131 @@ export default { ...@@ -24,82 +37,131 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
groupPathRegex: {
type: RegExp,
required: true,
}, },
computed: {
isDisabled() {
return this.group.status !== STATUSES.NONE;
}, },
isFinished() { apollo: {
return this.group.status === STATUSES.FINISHED; existingGroup: {
query: groupQuery,
debounce: DEBOUNCE_INTERVAL,
variables() {
return {
fullPath: this.fullPath,
};
},
skip() {
return !this.isNameValid || this.isAlreadyImported;
},
},
}, },
select2Options() { computed: {
const availableNamespacesData = this.availableNamespaces.map((namespace) => ({ importTarget() {
id: namespace.full_path, return this.group.import_target;
text: namespace.full_path, },
}));
const select2Config = { isInvalid() {
data: [{ id: '', text: s__('BulkImport|No parent') }], return Boolean(!this.isNameValid || this.existingGroup);
}; },
if (availableNamespacesData.length) { isNameValid() {
select2Config.data.push({ return this.groupPathRegex.test(this.importTarget.new_name);
text: s__('BulkImport|Existing groups'), },
children: availableNamespacesData,
});
}
return select2Config; isAlreadyImported() {
return this.group.status !== STATUSES.NONE;
}, },
isFinished() {
return this.group.status === STATUSES.FINISHED;
}, },
methods: {
getPath(group) { fullPath() {
return `${group.import_target.target_namespace}/${group.import_target.new_name}`; return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
}, },
getFullPath(group) { absolutePath() {
return joinPaths(gon.relative_url_root || '/', this.getPath(group)); return joinPaths(gon.relative_url_root || '/', this.fullPath);
}, },
}, },
}; };
</script> </script>
<template> <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"> <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" /> {{ group.full_path }} <gl-icon name="external-link" />
</gl-link> </gl-link>
</td> </td>
<td class="gl-p-4"> <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 <div
v-else v-else
class="import-entities-target-select gl-display-flex gl-align-items-stretch" class="import-entities-target-select gl-display-flex gl-align-items-stretch"
:class="{ :class="{
disabled: isDisabled, disabled: isAlreadyImported,
}" }"
> >
<select2-select <gl-dropdown
:disabled="isDisabled" :text="importTarget.target_namespace"
:options="select2Options" :disabled="isAlreadyImported"
:value="group.import_target.target_namespace" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
@input="$emit('update-target-namespace', $event)" 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 <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>
<div class="gl-flex-fill-1">
<gl-form-input <gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none" class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:disabled="isDisabled" :class="{ 'is-invalid': isInvalid && !isAlreadyImported }"
:value="group.import_target.new_name" :disabled="isAlreadyImported"
:value="importTarget.new_name"
@input="$emit('update-new-name', $event)" @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> </div>
</td> </td>
<td class="gl-p-4 gl-white-space-nowrap"> <td class="gl-p-4 gl-white-space-nowrap">
...@@ -107,7 +169,8 @@ export default { ...@@ -107,7 +169,8 @@ export default {
</td> </td>
<td class="gl-p-4"> <td class="gl-p-4">
<gl-button <gl-button
v-if="!isDisabled" v-if="!isAlreadyImported"
:disabled="isInvalid"
variant="success" variant="success"
category="secondary" category="secondary"
@click="$emit('import-group')" @click="$emit('import-group')"
......
query group($fullPath: ID!) {
existingGroup: group(fullPath: $fullPath) {
id
}
}
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import ImportTable from './components/import_table.vue'; import ImportTable from './components/import_table.vue';
import { createApolloClient } from './graphql/client_factory'; import { createApolloClient } from './graphql/client_factory';
...@@ -17,7 +16,7 @@ export function mountImportGroupsApp(mountElement) { ...@@ -17,7 +16,7 @@ export function mountImportGroupsApp(mountElement) {
createBulkImportPath, createBulkImportPath,
jobsPath, jobsPath,
sourceUrl, sourceUrl,
canCreateGroup, groupPathRegex,
} = mountElement.dataset; } = mountElement.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createApolloClient({ defaultClient: createApolloClient({
...@@ -38,7 +37,7 @@ export function mountImportGroupsApp(mountElement) { ...@@ -38,7 +37,7 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, { return createElement(ImportTable, {
props: { props: {
sourceUrl, sourceUrl,
canCreateGroup: parseBoolean(canCreateGroup), groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
}, },
}); });
}, },
......
@import 'mixins_and_variables_and_functions'; @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 { .import-jobs-to-col {
width: 39%; width: 39%;
} }
......
...@@ -9,4 +9,5 @@ ...@@ -9,4 +9,5 @@
available_namespaces_path: import_available_namespaces_path(format: :json), available_namespaces_path: import_available_namespaces_path(format: :json),
create_bulk_import_path: import_bulk_imports_path(format: :json), create_bulk_import_path: import_bulk_imports_path(format: :json),
jobs_path: realtime_changes_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 "" ...@@ -5093,6 +5093,9 @@ msgstr ""
msgid "BulkImport|Importing the group failed" msgid "BulkImport|Importing the group failed"
msgstr "" msgstr ""
msgid "BulkImport|Name already exists."
msgstr ""
msgid "BulkImport|No parent" msgid "BulkImport|No parent"
msgstr "" msgstr ""
......
import { GlButton, GlLink, GlFormInput } from '@gitlab/ui'; import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; 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 { STATUSES } from '~/import_entities/constants';
import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; 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'; import { availableNamespacesFixture } from '../graphql/fixtures';
Vue.use(VueApollo);
const getFakeGroup = (status) => ({ const getFakeGroup = (status) => ({
web_url: 'https://fake.host/', web_url: 'https://fake.host/',
full_path: 'fake_group_1', full_path: 'fake_group_1',
...@@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({ ...@@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({
status, status,
}); });
const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group';
const EXISTING_GROUP_PATH = 'existing-path';
describe('import table row', () => { describe('import table row', () => {
let wrapper; let wrapper;
let apolloProvider;
let group; let group;
const findByText = (cmp, text) => { const findByText = (cmp, text) => {
...@@ -26,12 +35,27 @@ describe('import table row', () => { ...@@ -26,12 +35,27 @@ describe('import table row', () => {
}; };
const findImportButton = () => findByText(GlButton, 'Import'); const findImportButton = () => findByText(GlButton, 'Import');
const findNameInput = () => wrapper.find(GlFormInput); const findNameInput = () => wrapper.find(GlFormInput);
const findNamespaceDropdown = () => wrapper.find(Select2Select); const findNamespaceDropdown = () => wrapper.find(GlDropdown);
const createComponent = (props) => { 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, { wrapper = shallowMount(ImportTableRow, {
apolloProvider,
propsData: { propsData: {
availableNamespaces: availableNamespacesFixture, availableNamespaces: availableNamespacesFixture,
groupPathRegex: /.*/,
...props, ...props,
}, },
}); });
...@@ -50,7 +74,6 @@ describe('import table row', () => { ...@@ -50,7 +74,6 @@ describe('import table row', () => {
it.each` it.each`
selector | sourceEvent | payload | event selector | sourceEvent | payload | event
${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'}
${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'}
${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'}
`('invokes $event', ({ selector, sourceEvent, payload, event }) => { `('invokes $event', ({ selector, sourceEvent, payload, event }) => {
...@@ -58,6 +81,16 @@ describe('import table row', () => { ...@@ -58,6 +81,16 @@ describe('import table row', () => {
expect(wrapper.emitted(event)).toBeDefined(); expect(wrapper.emitted(event)).toBeDefined();
expect(wrapper.emitted(event)[0][0]).toBe(payload); 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', () => { describe('when entity status is NONE', () => {
...@@ -81,12 +114,12 @@ describe('import table row', () => { ...@@ -81,12 +114,12 @@ describe('import table row', () => {
availableNamespaces: [], availableNamespaces: [],
}); });
const dropdownData = findNamespaceDropdown().props().options.data; const items = findNamespaceDropdown()
const noParentOption = dropdownData.find((o) => o.text === 'No parent'); .findAllComponents(GlDropdownItem)
const existingGroupOption = dropdownData.find((o) => o.text === 'Existing groups'); .wrappers.map((w) => w.text());
expect(noParentOption.id).toBe(''); expect(items[0]).toBe('No parent');
expect(existingGroupOption).toBeUndefined(); expect(items).toHaveLength(1);
}); });
it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { 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', () => { ...@@ -95,12 +128,12 @@ describe('import table row', () => {
availableNamespaces: availableNamespacesFixture, availableNamespaces: availableNamespacesFixture,
}); });
const dropdownData = findNamespaceDropdown().props().options.data; const [firstItem, ...rest] = findNamespaceDropdown()
const noParentOption = dropdownData.find((o) => o.text === 'No parent'); .findAllComponents(GlDropdownItem)
const existingGroupOption = dropdownData.find((o) => o.text === 'Existing groups'); .wrappers.map((w) => w.text());
expect(noParentOption.id).toBe(''); expect(firstItem).toBe('No parent');
expect(existingGroupOption.children).toHaveLength(availableNamespacesFixture.length); expect(rest).toHaveLength(availableNamespacesFixture.length);
}); });
describe('when entity status is SCHEDULING', () => { describe('when entity status is SCHEDULING', () => {
...@@ -137,4 +170,38 @@ describe('import table row', () => { ...@@ -137,4 +170,38 @@ describe('import table row', () => {
expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); 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', () => { ...@@ -43,6 +43,7 @@ describe('import table', () => {
wrapper = shallowMount(ImportTable, { wrapper = shallowMount(ImportTable, {
propsData: { propsData: {
sourceUrl: 'https://demo.host', sourceUrl: 'https://demo.host',
groupPathRegex: /.*/,
}, },
stubs: { stubs: {
GlSprintf, 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