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 {
type: String,
required: true,
},
canCreateGroup: {
type: Boolean,
required: false,
default: false,
},
},
data() {
......@@ -171,6 +176,7 @@ export default {
:key="group.id"
:group="group"
:available-namespaces="availableNamespaces"
:can-create-group="canCreateGroup"
@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 { 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';
......@@ -23,6 +24,11 @@ export default {
type: Array,
required: true,
},
canCreateGroup: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isDisabled() {
......@@ -34,11 +40,23 @@ export default {
},
select2Options() {
const availableNamespacesData = this.availableNamespaces.map((namespace) => ({
id: namespace.full_path,
text: namespace.full_path,
}));
if (!this.canCreateGroup) {
return { data: availableNamespacesData };
}
return {
data: this.availableNamespaces.map((namespace) => ({
id: namespace.full_path,
text: namespace.full_path,
})),
data: [
{ id: '', text: s__('BulkImport|No parent') },
{
text: s__('BulkImport|Existing groups'),
children: availableNamespacesData,
},
],
};
},
},
......
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';
......@@ -16,6 +17,7 @@ export function mountImportGroupsApp(mountElement) {
createBulkImportPath,
jobsPath,
sourceUrl,
canCreateGroup,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
......@@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
canCreateGroup: parseBoolean(canCreateGroup),
},
});
},
......
......@@ -37,8 +37,9 @@ class BulkImports::Entity < ApplicationRecord
validates :project, absence: true, if: :group
validates :group, absence: true, if: :project
validates :source_type, :source_full_path, :destination_name,
:destination_namespace, presence: true
validates :source_type, :source_full_path, :destination_name, 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_imported_entity_type
......
......@@ -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),
can_create_group: current_user.can_create_group?.to_s,
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
end
def transform_parent(context, import_entity, data)
current_user = context.current_user
namespace = Namespace.find_by_full_path(import_entity.destination_namespace)
unless import_entity.destination_namespace.blank?
namespace = Namespace.find_by_full_path(import_entity.destination_namespace)
data['parent_id'] = namespace.id
end
return data if namespace == current_user.namespace
data['parent_id'] = namespace.id
data
end
......
......@@ -5063,6 +5063,9 @@ msgstr ""
msgid "Bulk update"
msgstr ""
msgid "BulkImport|Existing groups"
msgstr ""
msgid "BulkImport|From source group"
msgstr ""
......@@ -5072,6 +5075,9 @@ msgstr ""
msgid "BulkImport|Importing the group failed"
msgstr ""
msgid "BulkImport|No parent"
msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total} from %{link}"
msgstr ""
......
......@@ -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', () => {
beforeEach(() => {
group = getFakeGroup(STATUSES.SCHEDULING);
......
......@@ -21,9 +21,13 @@ describe('import table', () => {
let apolloProvider;
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 createComponent = ({ bulkImportSourceGroups }) => {
const createComponent = ({ bulkImportSourceGroups, canCreateGroup }) => {
apolloProvider = createMockApollo([], {
Query: {
availableNamespaces: () => availableNamespacesFixture,
......@@ -39,6 +43,7 @@ describe('import table', () => {
wrapper = shallowMount(ImportTable, {
propsData: {
sourceUrl: 'https://demo.host',
canCreateGroup,
},
stubs: {
GlSprintf,
......@@ -84,10 +89,6 @@ describe('import table', () => {
});
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({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
......@@ -99,6 +100,25 @@ describe('import table', () => {
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 () => {
createComponent({
bulkImportSourceGroups: jest.fn().mockResolvedValue({
......
......@@ -80,14 +80,14 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
expect(transformed_data['parent_id']).to eq(parent.id)
end
context 'when destination namespace is user namespace' do
context 'when destination namespace is empty' do
it 'does not set parent id' do
entity = create(
:bulk_import_entity,
bulk_import: bulk_import,
source_full_path: 'source/full/path',
destination_name: group.name,
destination_namespace: user.namespace.full_path
destination_namespace: ''
)
context = BulkImports::Pipeline::Context.new(entity)
......
......@@ -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_full_path) }
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]) }
......@@ -38,7 +37,11 @@ RSpec.describe BulkImports::Entity, type: :model do
context 'when associated with a group and no project' do
it 'is valid as a group_entity' do
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
end
......@@ -57,6 +60,12 @@ RSpec.describe BulkImports::Entity, type: :model do
expect(entity).to be_valid
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
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