Commit ffb5a56a authored by Etienne Baqué's avatar Etienne Baqué

Merge branch 'ek-migrate-tranfer-project-modal-to-vue' into 'master'

Replace transfer project confirmation modal with GlModal

See merge request gitlab-org/gitlab!72361
parents d4fc263a 29b28220
......@@ -6,9 +6,9 @@ import initFilePickers from '~/file_pickers';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import initProjectDeleteButton from '~/projects/project_delete_button';
import initServiceDesk from '~/projects/settings_service_desk';
import initTransferProjectForm from '~/projects/settings/init_transfer_project_form';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import setupTransferEdit from '~/transfer_edit';
import UserCallout from '~/user_callout';
import initTopicsTokenSelector from '~/projects/settings/topics';
import initProjectPermissionsSettings from '../shared/permissions';
......@@ -26,7 +26,7 @@ initServiceDesk();
initProjectLoadingSpinner();
initProjectPermissionsSettings();
setupTransferEdit('.js-project-transfer-form', 'select.select2');
initTransferProjectForm();
dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form'));
......
<script>
import { GlFormGroup } from '@gitlab/ui';
import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
export default {
name: 'TransferProjectForm',
components: {
GlFormGroup,
NamespaceSelect,
ConfirmDanger,
},
props: {
namespaces: {
type: Object,
required: true,
},
confirmationPhrase: {
type: String,
required: true,
},
confirmButtonText: {
type: String,
required: true,
},
},
data() {
return { selectedNamespace: null };
},
computed: {
hasSelectedNamespace() {
return Boolean(this.selectedNamespace?.id);
},
},
methods: {
handleSelect(selectedNamespace) {
this.selectedNamespace = selectedNamespace;
this.$emit('selectNamespace', selectedNamespace.id);
},
},
};
</script>
<template>
<div>
<gl-form-group>
<namespace-select
class="qa-namespaces-list"
data-testid="transfer-project-namespace"
:full-width="true"
:data="namespaces"
:selected-namespace="selectedNamespace"
@select="handleSelect"
/>
</gl-form-group>
<confirm-danger
button-class="qa-transfer-button"
:disabled="!hasSelectedNamespace"
:phrase="confirmationPhrase"
:button-text="confirmButtonText"
@confirm="$emit('confirm')"
/>
</div>
</template>
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TransferProjectForm from './components/transfer_project_form.vue';
const prepareNamespaces = (rawNamespaces = '') => {
const data = JSON.parse(rawNamespaces);
return {
group: data?.group.map(convertObjectPropsToCamelCase),
user: data?.user.map(convertObjectPropsToCamelCase),
};
};
export default () => {
const el = document.querySelector('.js-transfer-project-form');
if (!el) {
return false;
}
const {
targetFormId = null,
targetHiddenInputId = null,
buttonText: confirmButtonText = '',
phrase: confirmationPhrase = '',
confirmDangerMessage = '',
namespaces = '',
} = el.dataset;
return new Vue({
el,
provide: {
confirmDangerMessage,
},
render(createElement) {
return createElement(TransferProjectForm, {
props: {
confirmButtonText,
confirmationPhrase,
namespaces: prepareNamespaces(namespaces),
},
on: {
selectNamespace: (id) => {
if (targetHiddenInputId && document.getElementById(targetHiddenInputId)) {
document.getElementById(targetHiddenInputId).value = id;
}
},
confirm: () => {
if (targetFormId) document.getElementById(targetFormId)?.submit();
},
},
});
},
});
};
......@@ -47,7 +47,7 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
attributes: [{ variant: 'danger', disabled: !this.isValid }],
attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }],
};
},
},
......@@ -95,7 +95,7 @@ export default {
<gl-form-input
id="confirm_name_input"
v-model="confirmationPhrase"
class="form-control"
class="form-control qa-confirm-input"
data-testid="confirm-danger-input"
type="text"
/>
......
<script>
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
import { __ } from '~/locale';
export const i18n = {
DEFAULT_TEXT: __('Select a new namespace'),
GROUPS: __('Groups'),
USERS: __('Users'),
};
const filterByName = (data, searchTerm = '') =>
data.filter((d) => d.humanName.toLowerCase().includes(searchTerm));
export default {
name: 'NamespaceSelect',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
},
props: {
data: {
type: Object,
required: true,
},
fullWidth: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
searchTerm: '',
selectedNamespace: null,
};
},
computed: {
hasUserNamespaces() {
return this.data.user?.length;
},
hasGroupNamespaces() {
return this.data.group?.length;
},
filteredGroupNamespaces() {
if (!this.hasGroupNamespaces) return [];
return filterByName(this.data.group, this.searchTerm);
},
filteredUserNamespaces() {
if (!this.hasUserNamespaces) return [];
return filterByName(this.data.user, this.searchTerm);
},
selectedNamespaceText() {
return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT;
},
},
methods: {
handleSelect(item) {
this.selectedNamespace = item;
this.$emit('select', item);
},
},
i18n,
};
</script>
<template>
<gl-dropdown :text="selectedNamespaceText" :block="fullWidth">
<template #header>
<gl-search-box-by-type v-model.trim="searchTerm" />
</template>
<div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups">
<gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredGroupNamespaces"
:key="item.id"
class="qa-namespaces-list-item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
<div v-if="hasUserNamespaces" class="qa-namespaces-list-users">
<gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredUserNamespaces"
:key="item.id"
class="qa-namespaces-list-item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
</gl-dropdown>
</template>
......@@ -119,7 +119,10 @@ class ProjectsController < Projects::ApplicationController
if @project.errors[:new_namespace].present?
flash[:alert] = @project.errors[:new_namespace].first
return redirect_to edit_project_path(@project)
end
render_edit
end
# rubocop: enable CodeReuse/ActiveRecord
......
......@@ -88,6 +88,13 @@ module NamespacesHelper
group.namespace_settings.public_send(method_name, **args) # rubocop:disable GitlabSecurity/PublicSend
end
def namespaces_as_json(selected = :current_user)
{
group: formatted_namespaces(current_user.manageable_groups_with_routes),
user: formatted_namespaces([current_user.namespace])
}.to_json
end
private
# Many importers create a temporary Group, so use the real
......@@ -119,6 +126,17 @@ module NamespacesHelper
[group_label.camelize, elements]
end
def formatted_namespaces(namespaces)
namespaces.sort_by(&:human_name).map! do |n|
{
id: n.id,
display_path: n.full_path,
human_name: n.human_name,
name: n.name
}
end
end
end
NamespacesHelper.prepend_mod_with('NamespacesHelper')
- return unless can?(current_user, :change_namespace, @project)
- form_id = "transfer-project-form"
- hidden_input_id = "new_namespace_id"
- initial_data = { namespaces: namespaces_as_json, button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id }
.sub-section
%h4.danger-title= _('Transfer project')
= form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f|
= form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f|
.form-group
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-another-namespace') }
%p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
......@@ -11,7 +14,6 @@
%li= _('You can only transfer the project to namespaces you manage.')
%li= _('You will need to update your local repositories to point to the new location.')
%li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
= hidden_field_tag(hidden_input_id)
= label_tag :new_namespace_id, _('Select a new namespace'), class: 'gl-font-weight-bold'
.form-group
= select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
= f.submit 'Transfer project', class: "gl-button btn btn-danger js-legacy-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
.js-transfer-project-form{ data: initial_data }
:plain
location.href = "#{edit_project_path(@project)}";
......@@ -5,7 +5,6 @@ module QA
module Project
module Settings
class Advanced < Page::Base
include Component::Select2
include Component::ConfirmModal
view 'app/views/projects/edit.html.haml' do
......@@ -13,8 +12,10 @@ module QA
element :change_path_button
end
view 'app/views/projects/_transfer.html.haml' do
element :transfer_button
view "app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue" do
element :namespaces_list
element :namespaces_list_groups
element :namespaces_list_item
end
view 'app/views/projects/settings/_archive.html.haml' do
......@@ -42,16 +43,22 @@ module QA
click_element :change_path_button
end
def select_namespace(item)
click_element :namespaces_list
within_element(:namespaces_list) do
find_element(:namespaces_list_item, text: item).click
end
end
def transfer_project!(project_name, namespace)
QA::Runtime::Logger.info "Transferring project: #{project_name} to namespace: #{namespace}"
click_element_coordinates(:archive_project_content)
expand_select_list
# Workaround for a failure to search when there are no spaces around the /
# https://gitlab.com/gitlab-org/gitlab/-/issues/218965
search_and_select(namespace.gsub(%r{([^\s])/([^\s])}, '\1 / \2'))
select_namespace(namespace.gsub(%r{([^\s])/([^\s])}, '\1 / \2'))
click_element(:transfer_button)
fill_confirmation_text(project_name)
......
......@@ -899,10 +899,34 @@ RSpec.describe ProjectsController do
describe '#transfer', :enable_admin_mode do
render_views
let_it_be(:project, reload: true) { create(:project) }
let(:project) { create(:project) }
let_it_be(:admin) { create(:admin) }
let_it_be(:new_namespace) { create(:namespace) }
shared_examples 'project namespace is not changed' do |flash_message|
it 'project namespace is not changed' do
controller.instance_variable_set(:@project, project)
sign_in(admin)
old_namespace = project.namespace
put :transfer,
params: {
namespace_id: old_namespace.path,
new_namespace_id: new_namespace_id,
id: project.path
},
format: :js
project.reload
expect(project.namespace).to eq(old_namespace)
expect(response).to redirect_to(edit_project_path(project))
expect(flash[:alert]).to eq flash_message
end
end
it 'updates namespace' do
sign_in(admin)
......@@ -921,26 +945,15 @@ RSpec.describe ProjectsController do
end
context 'when new namespace is empty' do
it 'project namespace is not changed' do
controller.instance_variable_set(:@project, project)
sign_in(admin)
let(:new_namespace_id) { nil }
old_namespace = project.namespace
put :transfer,
params: {
namespace_id: old_namespace.path,
new_namespace_id: nil,
id: project.path
},
format: :js
it_behaves_like 'project namespace is not changed', s_('TransferProject|Please select a new namespace for your project.')
end
project.reload
context 'when new namespace is the same as the current namespace' do
let(:new_namespace_id) { project.namespace.id }
expect(project.namespace).to eq(old_namespace)
expect(response).to have_gitlab_http_status(:ok)
expect(flash[:alert]).to eq s_('TransferProject|Please select a new namespace for your project.')
end
it_behaves_like 'project namespace is not changed', s_('TransferProject|Project is already in this namespace.')
end
end
......
......@@ -8,6 +8,8 @@ RSpec.describe 'Projects > Settings > User transfers a project', :js do
let(:group) { create(:group) }
before do
stub_const('Gitlab::QueryLimiting::Transaction::THRESHOLD', 120)
group.add_owner(user)
sign_in(user)
end
......@@ -16,10 +18,12 @@ RSpec.describe 'Projects > Settings > User transfers a project', :js do
visit edit_project_path(project)
page.within('.js-project-transfer-form') do
page.find('.select2-container').click
page.find('[data-testid="transfer-project-namespace"]').click
end
page.find("div[role='option']", text: group.full_name).click
page.within('[data-testid="transfer-project-namespace"]') do
page.find("li button", text: group.full_name).click
end
click_button('Transfer project')
......
import { namespaces } from 'jest/vue_shared/components/namespace_select/mock_data';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TransferProjectForm from '~/projects/settings/components/transfer_project_form.vue';
import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
describe('Transfer project form', () => {
let wrapper;
const confirmButtonText = 'Confirm';
const confirmationPhrase = 'You must construct additional pylons!';
const createComponent = () =>
shallowMountExtended(TransferProjectForm, {
propsData: {
namespaces,
confirmButtonText,
confirmationPhrase,
},
});
const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect);
const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger);
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the namespace selector', () => {
expect(findNamespaceSelect().exists()).toBe(true);
});
it('renders the confirm button', () => {
expect(findConfirmDanger().exists()).toBe(true);
});
it('disables the confirm button by default', () => {
expect(findConfirmDanger().attributes('disabled')).toBe('true');
});
describe('with a selected namespace', () => {
const [selectedItem] = namespaces.group;
beforeEach(() => {
findNamespaceSelect().vm.$emit('select', selectedItem);
});
it('emits the `selectNamespace` event when a namespace is selected', () => {
const args = [selectedItem.id];
expect(wrapper.emitted('selectNamespace')).toEqual([args]);
});
it('enables the confirm button', () => {
expect(findConfirmDanger().attributes('disabled')).toBeUndefined();
});
it('clicking the confirm button emits the `confirm` event', () => {
findConfirmDanger().vm.$emit('confirm');
expect(wrapper.emitted('confirm')).toBeDefined();
});
});
});
......@@ -4,11 +4,11 @@ import { loadHTMLFixture } from 'helpers/fixtures';
import setupTransferEdit from '~/transfer_edit';
describe('setupTransferEdit', () => {
const formSelector = '.js-project-transfer-form';
const targetSelector = 'select.select2';
const formSelector = '.js-group-transfer-form';
const targetSelector = '#new_parent_group_id';
beforeEach(() => {
loadHTMLFixture('projects/edit.html');
loadHTMLFixture('groups/edit.html');
setupTransferEdit(formSelector, targetSelector);
});
......@@ -17,8 +17,8 @@ describe('setupTransferEdit', () => {
});
it('enables submit button when selection changes to non-empty value', () => {
const nonEmptyValue = $(formSelector).find(targetSelector).find('option').not(':empty').val();
$(formSelector).find(targetSelector).val(nonEmptyValue).trigger('change');
const lastValue = $(formSelector).find(targetSelector).find('.dropdown-content li').last();
$(formSelector).find(targetSelector).val(lastValue).trigger('change');
expect($(formSelector).find(':submit').prop('disabled')).toBeFalsy();
});
......
export const group = [
{ id: 1, name: 'Group 1', humanName: 'Group 1' },
{ id: 2, name: 'Subgroup 1', humanName: 'Group 1 / Subgroup 1' },
];
export const user = [{ id: 3, name: 'User namespace 1', humanName: 'User namespace 1' }];
export const namespaces = {
group,
user,
};
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NamespaceSelect, {
i18n,
} from '~/vue_shared/components/namespace_select/namespace_select.vue';
import { user, group, namespaces } from './mock_data';
describe('Namespace Select', () => {
let wrapper;
const createComponent = (props = {}) =>
shallowMountExtended(NamespaceSelect, {
propsData: {
data: namespaces,
...props,
},
});
const wrappersText = (arr) => arr.wrappers.map((w) => w.text());
const flatNamespaces = () => [...group, ...user];
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownAttributes = (attr) => findDropdown().attributes(attr);
const selectedDropdownItemText = () => findDropdownAttributes('text');
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader);
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the dropdown', () => {
expect(findDropdown().exists()).toBe(true);
});
it('renders each dropdown item', () => {
const items = findDropdownItems().wrappers;
expect(items).toHaveLength(flatNamespaces().length);
});
it('renders the human name for each item', () => {
const dropdownItems = wrappersText(findDropdownItems());
const flatNames = flatNamespaces().map(({ humanName }) => humanName);
expect(dropdownItems).toEqual(flatNames);
});
it('sets the initial dropdown text', () => {
expect(selectedDropdownItemText()).toBe(i18n.DEFAULT_TEXT);
});
it('splits group and user namespaces', () => {
const headers = findSectionHeaders();
expect(headers).toHaveLength(2);
expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]);
});
it('sets the dropdown to full width', () => {
expect(findDropdownAttributes('block')).toBeUndefined();
wrapper = createComponent({ fullWidth: true });
expect(findDropdownAttributes('block')).not.toBeUndefined();
expect(findDropdownAttributes('block')).toBe('true');
});
describe('with a selected namespace', () => {
const selectedGroupIndex = 1;
const selectedItem = group[selectedGroupIndex];
beforeEach(() => {
findDropdownItems().at(selectedGroupIndex).vm.$emit('click');
});
it('sets the dropdown text', () => {
expect(selectedDropdownItemText()).toBe(selectedItem.humanName);
});
it('emits the `select` event when a namespace is selected', () => {
const args = [selectedItem];
expect(wrapper.emitted('select')).toEqual([args]);
});
});
});
......@@ -45,6 +45,39 @@ RSpec.describe NamespacesHelper do
user_group.add_owner(user)
end
describe '#namespaces_as_json' do
let(:result) { helper.namespaces_as_json(user) }
before do
allow(helper).to receive(:current_user).and_return(user)
end
it 'returns the user\'s groups' do
json_data = Gitlab::Json.parse(result)
expect(result).to include('group')
expect(json_data['group']).to include(
"id" => user_group.id,
"name" => user_group.name,
"display_path" => user_group.full_path,
"human_name" => user_group.human_name
)
end
it 'returns the user\'s namespace' do
user_namespace = user.namespace
json_data = Gitlab::Json.parse(result)
expect(result).to include('user')
expect(json_data['user']).to include(
"id" => user_namespace.id,
"name" => user_namespace.name,
"display_path" => user_namespace.full_path,
"human_name" => user_namespace.human_name
)
end
end
describe '#namespaces_options' do
context 'when admin mode is enabled', :enable_admin_mode do
it 'returns groups without being a member for admin' do
......
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