Commit dc204170 authored by Illya Klymov's avatar Illya Klymov Committed by Bob Van Landuyt

Allow importing groups as new top-level groups

- add option to dropdown
- respect user permissions
- relax model validation
parent 3262be6b
...@@ -33,6 +33,11 @@ export default { ...@@ -33,6 +33,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
canCreateGroup: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
...@@ -171,6 +176,7 @@ export default { ...@@ -171,6 +176,7 @@ export default {
:key="group.id" :key="group.id"
:group="group" :group="group"
:available-namespaces="availableNamespaces" :available-namespaces="availableNamespaces"
:can-create-group="canCreateGroup"
@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, 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 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';
...@@ -23,6 +24,11 @@ export default { ...@@ -23,6 +24,11 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
canCreateGroup: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
isDisabled() { isDisabled() {
...@@ -34,11 +40,23 @@ export default { ...@@ -34,11 +40,23 @@ export default {
}, },
select2Options() { select2Options() {
return { const availableNamespacesData = this.availableNamespaces.map((namespace) => ({
data: this.availableNamespaces.map((namespace) => ({
id: namespace.full_path, id: namespace.full_path,
text: namespace.full_path, text: namespace.full_path,
})), }));
if (!this.canCreateGroup) {
return { data: availableNamespacesData };
}
return {
data: [
{ id: '', text: s__('BulkImport|No parent') },
{
text: s__('BulkImport|Existing groups'),
children: availableNamespacesData,
},
],
}; };
}, },
}, },
......
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';
...@@ -16,6 +17,7 @@ export function mountImportGroupsApp(mountElement) { ...@@ -16,6 +17,7 @@ export function mountImportGroupsApp(mountElement) {
createBulkImportPath, createBulkImportPath,
jobsPath, jobsPath,
sourceUrl, sourceUrl,
canCreateGroup,
} = mountElement.dataset; } = mountElement.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createApolloClient({ defaultClient: createApolloClient({
...@@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) { ...@@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, { return createElement(ImportTable, {
props: { props: {
sourceUrl, sourceUrl,
canCreateGroup: parseBoolean(canCreateGroup),
}, },
}); });
}, },
......
...@@ -37,8 +37,9 @@ class BulkImports::Entity < ApplicationRecord ...@@ -37,8 +37,9 @@ class BulkImports::Entity < ApplicationRecord
validates :project, absence: true, if: :group validates :project, absence: true, if: :group
validates :group, absence: true, if: :project validates :group, absence: true, if: :project
validates :source_type, :source_full_path, :destination_name, validates :source_type, :source_full_path, :destination_name, presence: true
:destination_namespace, presence: true validates :destination_namespace, exclusion: [nil], if: :group
validates :destination_namespace, presence: true, if: :project
validate :validate_parent_is_a_group, if: :parent validate :validate_parent_is_a_group, if: :parent
validate :validate_imported_entity_type validate :validate_imported_entity_type
......
...@@ -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),
can_create_group: current_user.can_create_group?.to_s,
source_url: @source_url } } source_url: @source_url } }
---
title: Allow importing groups as new top-level groups
merge_request: 54323
author:
type: changed
...@@ -35,12 +35,11 @@ module BulkImports ...@@ -35,12 +35,11 @@ module BulkImports
end end
def transform_parent(context, import_entity, data) def transform_parent(context, import_entity, data)
current_user = context.current_user unless import_entity.destination_namespace.blank?
namespace = Namespace.find_by_full_path(import_entity.destination_namespace) namespace = Namespace.find_by_full_path(import_entity.destination_namespace)
return data if namespace == current_user.namespace
data['parent_id'] = namespace.id data['parent_id'] = namespace.id
end
data data
end end
......
...@@ -5063,6 +5063,9 @@ msgstr "" ...@@ -5063,6 +5063,9 @@ msgstr ""
msgid "Bulk update" msgid "Bulk update"
msgstr "" msgstr ""
msgid "BulkImport|Existing groups"
msgstr ""
msgid "BulkImport|From source group" msgid "BulkImport|From source group"
msgstr "" msgstr ""
...@@ -5072,6 +5075,9 @@ msgstr "" ...@@ -5072,6 +5075,9 @@ msgstr ""
msgid "BulkImport|Importing the group failed" msgid "BulkImport|Importing the group failed"
msgstr "" msgstr ""
msgid "BulkImport|No parent"
msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total} from %{link}" msgid "BulkImport|Showing %{start}-%{end} of %{total} from %{link}"
msgstr "" msgstr ""
......
...@@ -75,6 +75,33 @@ describe('import table row', () => { ...@@ -75,6 +75,33 @@ describe('import table row', () => {
}); });
}); });
it('renders only namespaces if user cannot create new group', () => {
createComponent({
canCreateGroup: false,
group: getFakeGroup(STATUSES.NONE),
});
const dropdownData = findNamespaceDropdown().props().options.data;
const noParentOption = dropdownData.find((o) => o.text === 'No parent');
expect(noParentOption).toBeUndefined();
expect(dropdownData).toHaveLength(availableNamespacesFixture.length);
});
it('renders no parent option in available namespaces if user can create new group', () => {
createComponent({
canCreateGroup: true,
group: getFakeGroup(STATUSES.NONE),
});
const dropdownData = findNamespaceDropdown().props().options.data;
const noParentOption = dropdownData.find((o) => o.text === 'No parent');
const existingGroupOption = dropdownData.find((o) => o.text === 'Existing groups');
expect(noParentOption.id).toBe('');
expect(existingGroupOption.children).toHaveLength(availableNamespacesFixture.length);
});
describe('when entity status is SCHEDULING', () => { describe('when entity status is SCHEDULING', () => {
beforeEach(() => { beforeEach(() => {
group = getFakeGroup(STATUSES.SCHEDULING); group = getFakeGroup(STATUSES.SCHEDULING);
......
...@@ -21,9 +21,13 @@ describe('import table', () => { ...@@ -21,9 +21,13 @@ describe('import table', () => {
let apolloProvider; let apolloProvider;
const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE });
const FAKE_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
const createComponent = ({ bulkImportSourceGroups }) => { const createComponent = ({ bulkImportSourceGroups, canCreateGroup }) => {
apolloProvider = createMockApollo([], { apolloProvider = createMockApollo([], {
Query: { Query: {
availableNamespaces: () => availableNamespacesFixture, availableNamespaces: () => availableNamespacesFixture,
...@@ -39,6 +43,7 @@ describe('import table', () => { ...@@ -39,6 +43,7 @@ describe('import table', () => {
wrapper = shallowMount(ImportTable, { wrapper = shallowMount(ImportTable, {
propsData: { propsData: {
sourceUrl: 'https://demo.host', sourceUrl: 'https://demo.host',
canCreateGroup,
}, },
stubs: { stubs: {
GlSprintf, GlSprintf,
...@@ -84,10 +89,6 @@ describe('import table', () => { ...@@ -84,10 +89,6 @@ describe('import table', () => {
}); });
it('renders import row for each group in response', async () => { it('renders import row for each group in response', async () => {
const FAKE_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
];
createComponent({ createComponent({
bulkImportSourceGroups: () => ({ bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS, nodes: FAKE_GROUPS,
...@@ -99,6 +100,25 @@ describe('import table', () => { ...@@ -99,6 +100,25 @@ describe('import table', () => {
expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length); expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length);
}); });
it.each`
canCreateGroup | userPermissions
${true} | ${'user can create new top-level group'}
${false} | ${'user cannot create new top-level group'}
`('correctly passes canCreateGroup to rows when $userPermissions', async ({ canCreateGroup }) => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
}),
canCreateGroup,
});
await waitForPromises();
wrapper.findAllComponents(ImportTableRow).wrappers.forEach((w) => {
expect(w.props().canCreateGroup).toBe(canCreateGroup);
});
});
it('does not render status string when result list is empty', async () => { it('does not render status string when result list is empty', async () => {
createComponent({ createComponent({
bulkImportSourceGroups: jest.fn().mockResolvedValue({ bulkImportSourceGroups: jest.fn().mockResolvedValue({
......
...@@ -80,14 +80,14 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do ...@@ -80,14 +80,14 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
expect(transformed_data['parent_id']).to eq(parent.id) expect(transformed_data['parent_id']).to eq(parent.id)
end end
context 'when destination namespace is user namespace' do context 'when destination namespace is empty' do
it 'does not set parent id' do it 'does not set parent id' do
entity = create( entity = create(
:bulk_import_entity, :bulk_import_entity,
bulk_import: bulk_import, bulk_import: bulk_import,
source_full_path: 'source/full/path', source_full_path: 'source/full/path',
destination_name: group.name, destination_name: group.name,
destination_namespace: user.namespace.full_path destination_namespace: ''
) )
context = BulkImports::Pipeline::Context.new(entity) context = BulkImports::Pipeline::Context.new(entity)
......
...@@ -14,7 +14,6 @@ RSpec.describe BulkImports::Entity, type: :model do ...@@ -14,7 +14,6 @@ RSpec.describe BulkImports::Entity, type: :model do
it { is_expected.to validate_presence_of(:source_type) } it { is_expected.to validate_presence_of(:source_type) }
it { is_expected.to validate_presence_of(:source_full_path) } it { is_expected.to validate_presence_of(:source_full_path) }
it { is_expected.to validate_presence_of(:destination_name) } it { is_expected.to validate_presence_of(:destination_name) }
it { is_expected.to validate_presence_of(:destination_namespace) }
it { is_expected.to define_enum_for(:source_type).with_values(%i[group_entity project_entity]) } it { is_expected.to define_enum_for(:source_type).with_values(%i[group_entity project_entity]) }
...@@ -38,7 +37,11 @@ RSpec.describe BulkImports::Entity, type: :model do ...@@ -38,7 +37,11 @@ RSpec.describe BulkImports::Entity, type: :model do
context 'when associated with a group and no project' do context 'when associated with a group and no project' do
it 'is valid as a group_entity' do it 'is valid as a group_entity' do
entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil) entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil)
expect(entity).to be_valid
end
it 'is valid when destination_namespace is empty' do
entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_namespace: '')
expect(entity).to be_valid expect(entity).to be_valid
end end
...@@ -57,6 +60,12 @@ RSpec.describe BulkImports::Entity, type: :model do ...@@ -57,6 +60,12 @@ RSpec.describe BulkImports::Entity, type: :model do
expect(entity).to be_valid expect(entity).to be_valid
end end
it 'is invalid when destination_namespace is nil' do
entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_namespace: nil)
expect(entity).not_to be_valid
expect(entity.errors).to include(:destination_namespace)
end
it 'is invalid as a project_entity' do it 'is invalid as a project_entity' do
entity = build(:bulk_import_entity, :group_entity, group: nil, project: build(:project)) entity = build(:bulk_import_entity, :group_entity, group: nil, project: build(:project))
......
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