Commit e6a071f1 authored by Josianne Hyson's avatar Josianne Hyson

Create group import UI

We want users to be able to create groups from imports in the UI as well
as the API. Create the user interface to achieve this.

For now, this is a copy of the layout and functionality of project
import but it is intended that we will iterate on this with a more
streamlined import process.

Relates to: https://gitlab.com/gitlab-org/gitlab/-/issues/211807
parent 8bde5395
import $ from 'jquery';
import { slugify } from './lib/utils/text_utility'; import { slugify } from './lib/utils/text_utility';
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
import flash from '~/flash'; import flash from '~/flash';
...@@ -6,44 +5,69 @@ import { __ } from '~/locale'; ...@@ -6,44 +5,69 @@ import { __ } from '~/locale';
export default class Group { export default class Group {
constructor() { constructor() {
this.groupPath = $('#group_path'); this.groupPaths = Array.from(document.querySelectorAll('.js-autofill-group-path'));
this.groupName = $('#group_name'); this.groupNames = Array.from(document.querySelectorAll('.js-autofill-group-name'));
this.parentId = $('#group_parent_id'); this.parentId = document.getElementById('group_parent_id');
this.updateHandler = this.update.bind(this); this.updateHandler = this.update.bind(this);
this.resetHandler = this.reset.bind(this); this.resetHandler = this.reset.bind(this);
this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this);
if (this.groupName.val() === '') {
this.groupName.on('keyup', this.updateHandler); this.groupNames.forEach(groupName => {
this.groupPath.on('keydown', this.resetHandler); if (groupName.value === '') {
if (!this.parentId.val()) { groupName.addEventListener('keyup', this.updateHandler);
this.groupName.on('blur', this.updateGroupPathSlugHandler);
if (!this.parentId.value) {
groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
}
} }
} });
this.groupPaths.forEach(groupPath => {
groupPath.addEventListener('keydown', this.resetHandler);
});
} }
update() { update({ currentTarget: { value: updatedValue } }) {
const slug = slugify(this.groupName.val()); const slug = slugify(updatedValue);
this.groupPath.val(slug);
this.groupNames.forEach(element => {
element.value = updatedValue;
});
this.groupPaths.forEach(element => {
element.value = slug;
});
} }
reset() { reset() {
this.groupName.off('keyup', this.updateHandler); this.groupNames.forEach(groupName => {
this.groupPath.off('keydown', this.resetHandler); groupName.removeEventListener('keyup', this.updateHandler);
this.groupName.off('blur', this.checkPathHandler); groupName.removeEventListener('blur', this.checkPathHandler);
});
this.groupPaths.forEach(groupPath => {
groupPath.removeEventListener('keydown', this.resetHandler);
});
} }
updateGroupPathSlug() { updateGroupPathSlug({ currentTarget: { value } = '' } = {}) {
const slug = this.groupPath.val() || slugify(this.groupName.val()); const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return; if (!slug) return;
fetchGroupPathAvailability(slug) fetchGroupPathAvailability(slug)
.then(({ data }) => data) .then(({ data }) => data)
.then(data => { .then(({ exists, suggests }) => {
if (data.exists && data.suggests.length > 0) { if (exists && suggests.length) {
const suggestedSlug = data.suggests[0]; const [suggestedSlug] = suggests;
this.groupPath.val(suggestedSlug);
this.groupPaths.forEach(element => {
element.value = suggestedSlug;
});
} else if (exists && !suggests.length) {
flash(__('Unable to suggest a path. Please refresh and try again.'));
} }
}) })
.catch(() => flash(__('An error occurred while checking group path'))); .catch(() =>
flash(__('An error occurred while checking group path. Please refresh and try again.')),
);
} }
} }
...@@ -4,6 +4,7 @@ import initAvatarPicker from '~/avatar_picker'; ...@@ -4,6 +4,7 @@ import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll(); BindInOut.initAll();
new Group(); // eslint-disable-line no-new
initAvatarPicker(); initAvatarPicker();
return new Group();
}); });
...@@ -11,7 +11,6 @@ import projectSelect from '~/project_select'; ...@@ -11,7 +11,6 @@ import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initAvatarPicker(); initAvatarPicker();
new TransferDropdown(); // eslint-disable-line no-new
initConfirmDangerModal(); initConfirmDangerModal();
initSettingsPanels(); initSettingsPanels();
dirtySubmitFactory( dirtySubmitFactory(
...@@ -24,4 +23,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -24,4 +23,6 @@ document.addEventListener('DOMContentLoaded', () => {
groupsSelect(); groupsSelect();
projectSelect(); projectSelect();
return new TransferDropdown();
}); });
...@@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
new GroupPathValidator(); // eslint-disable-line no-new new GroupPathValidator(); // eslint-disable-line no-new
} }
BindInOut.initAll(); BindInOut.initAll();
new Group(); // eslint-disable-line no-new
initAvatarPicker(); initAvatarPicker();
return new Group();
}); });
- parent = @group.parent
- group_path = root_url
- group_path << parent.full_path + '/' if parent
= form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f|
= form_errors(@group)
.row
.form-group.group-name.col-sm-12
= f.label :name, _('Group name'), class: 'label-bold'
= f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name form-control input-lg',
required: true,
title: _('Please fill in a descriptive name for your group.'),
autofocus: true
.row
.form-group.col-xs-12.col-sm-8
= f.label :path, _('Group URL'), class: 'label-bold'
.input-group.gl-field-error-anchor
.group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' }
.input-group-text
%span
= root_url
- if parent
%strong= parent.full_path + '/'
= f.hidden_field :parent_id, value: parent&.id
= f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path js-autofill-group-path',
id: 'import_group_path',
required: true,
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: _('Please choose a group URL with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
%p.validation-error.gl-field-error.field-validation.hide
= _('Group path is already taken. Suggestions: ')
%span.gl-path-suggestions
%p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...')
.row
.form-group.col-md-12
= s_('GroupsNew|To copy a GitLab group between installations, navigate to the group settings page for the original installation, generate an export file, and upload it here.')
.row
.form-group.col-sm-12
= f.label :file, s_('GroupsNew|GitLab group export'), class: 'label-bold'
.form-group
= f.file_field :file
.row
.form-actions.col-sm-12
= f.submit s_('GroupsNew|Import group'), class: 'btn btn-success'
= link_to _('Cancel'), new_group_path, class: 'btn btn-cancel'
= form_errors(@group)
= render 'shared/group_form', f: f, autofocus: true
.row
.form-group.group-description-holder.col-sm-12
= f.label :avatar, _("Group avatar"), class: 'label-bold'
%div
= render 'shared/choose_avatar_button', f: f
.form-group.col-sm-12
%label.label-bold
= _('Visibility level')
%p
= _('Who will be able to see this group?')
= link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank'
= render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
= render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
.form-actions.col-sm-12
= f.submit _('Create group'), class: "btn btn-success"
= link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel'
...@@ -2,43 +2,44 @@ ...@@ -2,43 +2,44 @@
- @hide_top_links = true - @hide_top_links = true
- page_title _('New Group') - page_title _('New Group')
- header_title _("Groups"), dashboard_groups_path - header_title _("Groups"), dashboard_groups_path
- active_tab = local_assigns.fetch(:active_tab, 'create')
.page-title-holder.d-flex.align-items-center .group-edit-container.prepend-top-default
%h1.page-title= _('New group') .row
.row.prepend-top-default .col-lg-3.group-settings-sidebar
.col-lg-3.profile-settings-sidebar %h4.prepend-top-0
%p = _('New group')
- group_docs_path = help_page_path('user/group/index') %p
- group_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_docs_path } - group_docs_path = help_page_path('user/group/index')
= s_('%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.').html_safe % { group_docs_link_start: group_docs_link_start, group_docs_link_end: '</a>'.html_safe } - group_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_docs_path }
%p = s_('%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.').html_safe % { group_docs_link_start: group_docs_link_start, group_docs_link_end: '</a>'.html_safe }
- subgroup_docs_path = help_page_path('user/group/subgroups/index') %p
- subgroup_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: subgroup_docs_path } - subgroup_docs_path = help_page_path('user/group/subgroups/index')
= s_('Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}.').html_safe % { subgroup_docs_link_start: subgroup_docs_link_start, subgroup_docs_link_end: '</a>'.html_safe } - subgroup_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: subgroup_docs_path }
%p = s_('Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}.').html_safe % { subgroup_docs_link_start: subgroup_docs_link_start, subgroup_docs_link_end: '</a>'.html_safe }
= _('Projects that belong to a group are prefixed with the group namespace. Existing projects may be moved into a group.') %p
= _('Projects that belong to a group are prefixed with the group namespace. Existing projects may be moved into a group.')
.col-lg-9 .col-lg-9.js-toggle-container
= form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| %ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' }
= form_errors(@group) %li.nav-item{ role: 'presentation' }
= render 'shared/group_form', f: f, autofocus: true %a.nav-link.active{ href: '#create-group-pane', id: 'create-group-tab', role: 'tab', data: { toggle: 'tab', track_label: 'create_group', track_event: 'click_tab', track_value: '' } }
%span.d-none.d-sm-block= s_('GroupsNew|Create group')
%span.d-block.d-sm-none= s_('GroupsNew|Create')
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#import-group-pane', id: 'import-group-tab', role: 'tab', data: { toggle: 'tab', track_label: 'import_group', track_event: 'click_tab', track_value: '' } }
%span.d-none.d-sm-block= s_('GroupsNew|Import group')
%span.d-block.d-sm-none= s_('GroupsNew|Import')
.row .tab-content.gitlab-tab-content
.form-group.group-description-holder.col-sm-12 .tab-pane.js-toggle-container{ id: 'create-group-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
= f.label :avatar, _("Group avatar"), class: 'label-bold' = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f|
%div = render 'new_group_fields', f: f, group_name_id: 'create-group-name'
= render 'shared/choose_avatar_button', f: f
.form-group.col-sm-12 .tab-pane.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' }
%label.label-bold - if import_sources_enabled?
= _('Visibility level') = render 'import_group_pane', active_tab: active_tab, autofocus: true
%p - else
= _('Who will be able to see this group?') .nothing-here-block
= link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank' %h4= s_('GroupsNew|No import options available')
= render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false %p= s_('GroupsNew|Contact an administrator to enable options for importing your group.')
= render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
.form-actions
= f.submit _('Create group'), class: "btn btn-success"
= link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel'
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
.form-group.group-name-holder.col-sm-12 .form-group.group-name-holder.col-sm-12
= f.label :name, class: 'label-bold' do = f.label :name, class: 'label-bold' do
= _("Group name") = _("Group name")
= f.text_field :name, placeholder: _('My Awesome Group'), class: 'form-control input-lg', = f.text_field :name, placeholder: _('My Awesome Group'), class: 'js-autofill-group-name form-control input-lg',
required: true, required: true,
title: _('Please fill in a descriptive name for your group.'), title: _('Please fill in a descriptive name for your group.'),
autofocus: true autofocus: true
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
- if parent - if parent
%strong= parent.full_path + '/' %strong= parent.full_path + '/'
= f.hidden_field :parent_id = f.hidden_field :parent_id
= f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path', = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path js-autofill-group-path',
autofocus: local_assigns[:autofocus] || false, required: true, autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: _('Please choose a group URL with no special characters.'), title: _('Please choose a group URL with no special characters.'),
......
---
title: Create Group import UI for creating new Groups
merge_request: 29271
author:
type: added
...@@ -67,7 +67,7 @@ For more details on the specific data persisted in a group export, see the ...@@ -67,7 +67,7 @@ For more details on the specific data persisted in a group export, see the
1. In the **Advanced** section, click the **Export Group** button. 1. In the **Advanced** section, click the **Export Group** button.
![Export group panel](img/export_panel.png) ![Export group panel](img/export_panel_v13_0.png)
1. Once the export is generated, you should receive an e-mail with a link to the [exported contents](#exported-contents) 1. Once the export is generated, you should receive an e-mail with a link to the [exported contents](#exported-contents)
in a compressed tar archive, with contents in JSON format. in a compressed tar archive, with contents in JSON format.
...@@ -85,6 +85,27 @@ You can export groups from the [Community Edition to the Enterprise Edition](htt ...@@ -85,6 +85,27 @@ You can export groups from the [Community Edition to the Enterprise Edition](htt
If you're exporting a group from the Enterprise Edition to the Community Edition, you may lose data that is retained only in the Enterprise Edition. For more information, see [downgrading from EE to CE](../../../README.md). If you're exporting a group from the Enterprise Edition to the Community Edition, you may lose data that is retained only in the Enterprise Edition. For more information, see [downgrading from EE to CE](../../../README.md).
## Importing the group
1. Navigate to the New Group page, either via the `+` button in the top navigation bar, or the **New subgroup** button
on an existing group's page.
![Navigation paths to create a new group](img/new_group_navigation_v13_1.png)
1. On the New Group page, select the **Import group** tab.
![Fill in group details](img/import_panel_v13_1.png)
1. Enter your group name.
1. Accept or modify the associated group URL.
1. Click **Choose file**
1. Select the file that you exported in the [exporting a group](#exporting-a-group) section.
1. Click **Import group** to begin importing. Your newly imported group page will appear shortly.
## Version history ## Version history
GitLab can import bundles that were exported from a different GitLab deployment. GitLab can import bundles that were exported from a different GitLab deployment.
...@@ -106,3 +127,4 @@ To help avoid abuse, users are rate limited to: ...@@ -106,3 +127,4 @@ To help avoid abuse, users are rate limited to:
| ---------------- | ---------------------------------------- | | ---------------- | ---------------------------------------- |
| Export | 30 groups every 5 minutes | | Export | 30 groups every 5 minutes |
| Download export | 10 downloads per group every 10 minutes | | Download export | 10 downloads per group every 10 minutes |
| Import | 30 groups every 5 minutes |
...@@ -2227,7 +2227,7 @@ msgstr "" ...@@ -2227,7 +2227,7 @@ msgstr ""
msgid "An error occurred while adding formatted title for epic" msgid "An error occurred while adding formatted title for epic"
msgstr "" msgstr ""
msgid "An error occurred while checking group path" msgid "An error occurred while checking group path. Please refresh and try again."
msgstr "" msgstr ""
msgid "An error occurred while committing your changes." msgid "An error occurred while committing your changes."
...@@ -11396,6 +11396,33 @@ msgstr "" ...@@ -11396,6 +11396,33 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group." msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "" msgstr ""
msgid "GroupsNew|Contact an administrator to enable options for importing your group."
msgstr ""
msgid "GroupsNew|Create"
msgstr ""
msgid "GroupsNew|Create group"
msgstr ""
msgid "GroupsNew|GitLab group export"
msgstr ""
msgid "GroupsNew|Import"
msgstr ""
msgid "GroupsNew|Import group"
msgstr ""
msgid "GroupsNew|My Awesome Group"
msgstr ""
msgid "GroupsNew|No import options available"
msgstr ""
msgid "GroupsNew|To copy a GitLab group between installations, navigate to the group settings page for the original installation, generate an export file, and upload it here."
msgstr ""
msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?" msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
msgstr "" msgstr ""
...@@ -23912,6 +23939,9 @@ msgstr "" ...@@ -23912,6 +23939,9 @@ msgstr ""
msgid "Unable to sign you in to the group with SAML due to \"%{reason}\"" msgid "Unable to sign you in to the group with SAML due to \"%{reason}\""
msgstr "" msgstr ""
msgid "Unable to suggest a path. Please refresh and try again."
msgstr ""
msgid "Unable to update label prioritization at this time" msgid "Unable to update label prioritization at this time"
msgstr "" msgstr ""
......
...@@ -10,7 +10,7 @@ module QA ...@@ -10,7 +10,7 @@ module QA
element :group_description_field, 'text_area :description' # rubocop:disable QA/ElementWithPattern element :group_description_field, 'text_area :description' # rubocop:disable QA/ElementWithPattern
end end
view 'app/views/groups/new.html.haml' do view 'app/views/groups/_new_group_fields.html.haml' do
element :create_group_button, "submit _('Create group')" # rubocop:disable QA/ElementWithPattern element :create_group_button, "submit _('Create group')" # rubocop:disable QA/ElementWithPattern
element :visibility_radios, 'visibility_level:' # rubocop:disable QA/ElementWithPattern element :visibility_radios, 'visibility_level:' # rubocop:disable QA/ElementWithPattern
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Import/Export - Group Import', :js do
let_it_be(:user) { create(:user) }
let_it_be(:import_path) { "#{Dir.tmpdir}/group_import_spec" }
before do
allow_next_instance_of(Gitlab::ImportExport) do |import_export|
allow(import_export).to receive(:storage_path).and_return(import_path)
end
stub_uploads_object_storage(FileUploader)
gitlab_sign_in(user)
end
after do
FileUtils.rm_rf(import_path, secure: true)
end
context 'when the user uploads a valid export file' do
let(:file) { File.join(Rails.root, 'spec', %w[fixtures group_export.tar.gz]) }
context 'when using the pre-filled path', :sidekiq_inline do
it 'successfully imports the group' do
group_name = 'Test Group Import'
visit new_group_path
fill_in :group_name, with: group_name
find('#import-group-tab').click
expect(page).to have_content 'GitLab group export'
attach_file('file', file)
expect { click_on 'Import group' }.to change { Group.count }.by 1
group = Group.find_by(name: group_name)
expect(group).not_to be_nil
expect(group.description).to eq 'A voluptate non sequi temporibus quam at.'
expect(group.path).to eq 'test-group-import'
expect(group.import_state.status).to eq GroupImportState.state_machine.states[:finished].value
end
end
context 'when modifying the pre-filled path' do
it 'successfully imports the group' do
visit new_group_path
fill_in :group_name, with: 'Test Group Import'
find('#import-group-tab').click
fill_in :import_group_path, with: 'custom-path'
attach_file('file', file)
expect { click_on 'Import group' }.to change { Group.count }.by 1
group = Group.find_by(name: 'Test Group Import')
expect(group.path).to eq 'custom-path'
end
end
context 'when the path is already taken' do
before do
create(:group, path: 'test-group-import')
end
it 'suggests a unique path' do
visit new_group_path
find('#import-group-tab').click
fill_in :import_group_path, with: 'test-group-import'
expect(page).to have_content 'Group path is already taken. Suggestions: test-group-import1'
end
end
end
context 'when the user uploads an invalid export file' do
let(:file) { File.join(Rails.root, 'spec', %w[fixtures big-image.png]) }
it 'displays an error' do
visit new_group_path
fill_in :group_name, with: 'Test Group Import'
find('#import-group-tab').click
attach_file('file', file)
expect { click_on 'Import group' }.not_to change { Group.count }
page.within('.flash-container') do
expect(page).to have_content('Unable to process group import file')
end
end
end
end
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