Commit ae951b44 authored by Nikola Milojevic's avatar Nikola Milojevic

Merge branch '348191-ensure-projects-are-created-within-a-group-mvc' into 'master'

Nudge user to create new projects within a group

See merge request gitlab-org/gitlab!80979
parents 9e09dbac 8316f1ae
......@@ -13,6 +13,7 @@ import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import { s__ } from '~/locale';
import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
import eventHub from '../event_hub';
......@@ -43,14 +44,7 @@ export default {
debounce: DEBOUNCE_DELAY,
},
},
inject: [
'namespaceFullPath',
'namespaceId',
'rootUrl',
'trackLabel',
'userNamespaceFullPath',
'userNamespaceId',
],
inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel', 'userNamespaceId'],
data() {
return {
currentUser: {},
......@@ -62,10 +56,11 @@ export default {
fullPath: this.namespaceFullPath,
}
: {
id: this.userNamespaceId,
fullPath: this.userNamespaceFullPath,
id: undefined,
fullPath: s__('ProjectsNew|Pick a group or namespace'),
},
shouldSkipQuery: true,
userNamespaceId: this.userNamespaceId,
};
},
computed: {
......@@ -92,6 +87,9 @@ export default {
hasNoMatches() {
return !this.hasGroupMatches && !this.hasNamespaceMatches;
},
dropdownPlaceholderClass() {
return this.selectedNamespace.id ? '' : 'gl-text-gray-500!';
},
},
created() {
eventHub.$on('select-template', this.handleSelectTemplate);
......@@ -130,11 +128,18 @@ export default {
</script>
<template>
<gl-button-group class="input-lg">
<gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button>
<gl-button-group class="gl-w-full">
<gl-button
class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!"
label
:title="rootUrl"
>{{ rootUrl }}</gl-button
>
<gl-dropdown
:text="selectedNamespace.fullPath"
toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
class="js-group-namespace-dropdown gl-flex-grow-1"
:toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`"
data-qa-selector="select_namespace_dropdown"
@show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
@shown="handleDropdownShown"
......@@ -166,11 +171,13 @@ export default {
</template>
</gl-dropdown>
<input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" />
<input
id="project_namespace_id"
type="hidden"
name="project[namespace_id]"
:value="selectedNamespace.id"
:value="selectedNamespace.id || userNamespaceId"
/>
</gl-button-group>
</template>
......@@ -58,7 +58,6 @@ export function initNewProjectUrlSelect() {
namespaceId: el.dataset.namespaceId,
rootUrl: el.dataset.rootUrl,
trackLabel: el.dataset.trackLabel,
userNamespaceFullPath: el.dataset.userNamespaceFullPath,
userNamespaceId: el.dataset.userNamespaceId,
},
render: (createElement) => createElement(NewProjectUrlSelect),
......
......@@ -14,6 +14,7 @@ import {
let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline';
const invalidDropdownClass = 'gl-inset-border-1-red-400!';
const cancelSource = axios.CancelToken.source();
const endpoint = `${gon.relative_url_root}/import/url/validate`;
......@@ -50,6 +51,25 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr
}
};
const selectedNamespaceId = () => document.querySelector('[name="project[selected_namespace_id]"]');
const dropdownButton = () => document.querySelector('.js-group-namespace-dropdown > button');
const namespaceButton = () => document.querySelector('.js-group-namespace-button');
const namespaceError = () => document.querySelector('.js-group-namespace-error');
const validateGroupNamespaceDropdown = (e) => {
if (selectedNamespaceId() && !selectedNamespaceId().attributes.value) {
document.querySelector('input[data-qa-selector="project_name"]').reportValidity();
e.preventDefault();
dropdownButton().classList.add(invalidDropdownClass);
namespaceButton().classList.add(invalidDropdownClass);
namespaceError().classList.remove('gl-display-none');
} else {
dropdownButton().classList.remove(invalidDropdownClass);
namespaceButton().classList.remove(invalidDropdownClass);
namespaceError().classList.add('gl-display-none');
}
};
const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
const specialRepo = document.querySelector('.js-user-readme-repo');
......@@ -70,6 +90,10 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
$projectPathInput.val() !== $projectPathInput.data('username'),
);
});
document.querySelector('.js-create-project-button').addEventListener('click', (e) => {
validateGroupNamespaceDropdown(e);
});
};
const deriveProjectPathFromUrl = ($projectImportUrl) => {
......
......@@ -68,6 +68,13 @@ class ProjectsController < Projects::ApplicationController
@namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace)
@current_user_group =
if current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count == 1
current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).first
else
nil
end
@project = Project.new(namespace_id: @namespace&.id)
end
# rubocop: enable CodeReuse/ActiveRecord
......
......@@ -9,27 +9,29 @@
= f.label :name, class: 'label-bold' do
%span= _("Project name")
= f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
.form-group.project-path.col-sm-6
.form-group.project-path.col-sm-6.gl-pr-0
= f.label :namespace_id, class: 'label-bold' do
%span= _('Project URL')
.input-group.gl-flex-nowrap
- if current_user.can_select_namespace?
- namespace_id = namespace_id_from(params)
.js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path,
namespace_id: namespace_id,
.js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path || @current_user_group&.full_path,
namespace_id: namespace_id || @current_user_group&.id,
root_url: root_url,
track_label: track_label,
user_namespace_full_path: current_user.namespace.full_path,
user_namespace_id: current_user.namespace.id } }
- else
.input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
#{user_url(current_user.username)}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
.gl-align-self-center.gl-pl-5 /
.form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do
%span= _("Project slug")
= f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_path', username: current_user.username }
.js-group-namespace-error.form-text.gl-text-red-500.gl-display-none
= s_('ProjectsNew|Pick a group or namespace where you want to create this project.')
- if current_user.can_create_group?
.form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
......@@ -73,5 +75,5 @@
= s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
= link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed' }
= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
= f.submit _('Create project'), class: "btn gl-button btn-confirm js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
......@@ -80,6 +80,9 @@ RSpec.describe 'New project', :js do
wait_for_requests
click_on 'Pick a group or namespace'
click_on user.username
fill_in 'project_name', with: 'import-project-with-features1'
fill_in 'project_path', with: 'import-project-with-features1'
choose 'project_visibility_level_20'
......@@ -103,6 +106,8 @@ RSpec.describe 'New project', :js do
fill_in 'project_import_url', with: 'http://foo.git'
fill_in 'project_name', with: 'CI CD Project1'
fill_in 'project_path', with: 'ci-cd-project1'
click_on 'Pick a group or namespace'
click_on user.username
choose 'project_visibility_level_20'
click_button 'Create project'
......
......@@ -29885,6 +29885,12 @@ msgstr ""
msgid "ProjectsNew|No import options available"
msgstr ""
msgid "ProjectsNew|Pick a group or namespace"
msgstr ""
msgid "ProjectsNew|Pick a group or namespace where you want to create this project."
msgstr ""
msgid "ProjectsNew|Project Configuration"
msgstr ""
......
......@@ -90,7 +90,12 @@ module QA
Page::Project::New.perform(&:click_blank_project_link)
Page::Project::New.perform do |new_page|
new_page.choose_test_namespace unless @personal_namespace
if @personal_namespace
new_page.choose_namespace(@personal_namespace)
else
new_page.choose_test_namespace
end
new_page.choose_name(@name)
new_page.add_description(@description)
new_page.set_visibility(@visibility)
......
......@@ -191,7 +191,8 @@ RSpec.describe 'New project', :js do
click_link 'Create blank project'
end
it 'selects the user namespace' do
it 'does not select the user namespace' do
click_on 'Pick a group or namespace'
expect(page).to have_button user.username
end
end
......@@ -328,6 +329,14 @@ RSpec.describe 'New project', :js do
click_on 'Create project'
expect(page).to have_content(
s_('ProjectsNew|Pick a group or namespace where you want to create this project.')
)
click_on 'Pick a group or namespace'
click_on user.username
click_on 'Create project'
expect(page).to have_css('#import-project-pane.active')
expect(page).not_to have_css('.toggle-import-form.hide')
end
......
......@@ -70,7 +70,7 @@ RSpec.describe 'User creates a project', :js do
fill_in :project_name, with: 'A Subgroup Project'
fill_in :project_path, with: 'a-subgroup-project'
click_button user.username
click_on 'Pick a group or namespace'
click_button subgroup.full_path
click_button('Create project')
......@@ -97,9 +97,6 @@ RSpec.describe 'User creates a project', :js do
fill_in :project_name, with: 'a-new-project'
fill_in :project_path, with: 'a-new-project'
click_button user.username
click_button group.full_path
page.within('#content-body') do
click_button('Create project')
end
......
......@@ -15,6 +15,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/projects/new/event_hub';
import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue';
import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
import { s__ } from '~/locale';
describe('NewProjectUrlSelect component', () => {
let wrapper;
......@@ -61,7 +62,6 @@ describe('NewProjectUrlSelect component', () => {
namespaceId: '28',
rootUrl: 'https://gitlab.com/',
trackLabel: 'blank_project',
userNamespaceFullPath: 'root',
userNamespaceId: '1',
};
......@@ -91,7 +91,10 @@ describe('NewProjectUrlSelect component', () => {
const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
const findHiddenInput = () => wrapper.find('[name="project[namespace_id]"]');
const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]');
const findHiddenSelectedNamespaceInput = () =>
wrapper.find('[name="project[selected_namespace_id]"]');
const clickDropdownItem = async () => {
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
......@@ -122,11 +125,20 @@ describe('NewProjectUrlSelect component', () => {
});
it('renders a dropdown with the given namespace full path as the text', () => {
expect(findDropdown().props('text')).toBe(defaultProvide.namespaceFullPath);
const dropdownProps = findDropdown().props();
expect(dropdownProps.text).toBe(defaultProvide.namespaceFullPath);
expect(dropdownProps.toggleClass).not.toContain('gl-text-gray-500!');
});
it('renders a hidden input with the given namespace id', () => {
expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.namespaceId);
});
it('renders a dropdown with the given namespace id in the hidden input', () => {
expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId);
it('renders a hidden input with the selected namespace id', () => {
expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(
defaultProvide.namespaceId,
);
});
});
......@@ -142,11 +154,18 @@ describe('NewProjectUrlSelect component', () => {
});
it("renders a dropdown with the user's namespace full path as the text", () => {
expect(findDropdown().props('text')).toBe(defaultProvide.userNamespaceFullPath);
const dropdownProps = findDropdown().props();
expect(dropdownProps.text).toBe(s__('ProjectsNew|Pick a group or namespace'));
expect(dropdownProps.toggleClass).toContain('gl-text-gray-500!');
});
it("renders a hidden input with the user's namespace id", () => {
expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.userNamespaceId);
});
it("renders a dropdown with the user's namespace id in the hidden input", () => {
expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId);
it('renders a hidden input with the selected namespace id', () => {
expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(undefined);
});
});
......@@ -270,7 +289,7 @@ describe('NewProjectUrlSelect component', () => {
await clickDropdownItem();
expect(findHiddenInput().attributes('value')).toBe(
expect(findHiddenNamespaceInput().attributes('value')).toBe(
getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(),
);
});
......
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