Commit 8316f1ae authored by Dallas Reedy's avatar Dallas Reedy Committed by Nikola Milojevic

Nudge users to create new projects within a group

- Stop defaulting to the user's personal namespace
- If user only has one group, default to that group
- Otherwise, prompt the user to select a group
parent fe7b5313
...@@ -13,6 +13,7 @@ import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; ...@@ -13,6 +13,7 @@ import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; 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 searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
...@@ -43,14 +44,7 @@ export default { ...@@ -43,14 +44,7 @@ export default {
debounce: DEBOUNCE_DELAY, debounce: DEBOUNCE_DELAY,
}, },
}, },
inject: [ inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel', 'userNamespaceId'],
'namespaceFullPath',
'namespaceId',
'rootUrl',
'trackLabel',
'userNamespaceFullPath',
'userNamespaceId',
],
data() { data() {
return { return {
currentUser: {}, currentUser: {},
...@@ -62,10 +56,11 @@ export default { ...@@ -62,10 +56,11 @@ export default {
fullPath: this.namespaceFullPath, fullPath: this.namespaceFullPath,
} }
: { : {
id: this.userNamespaceId, id: undefined,
fullPath: this.userNamespaceFullPath, fullPath: s__('ProjectsNew|Pick a group or namespace'),
}, },
shouldSkipQuery: true, shouldSkipQuery: true,
userNamespaceId: this.userNamespaceId,
}; };
}, },
computed: { computed: {
...@@ -92,6 +87,9 @@ export default { ...@@ -92,6 +87,9 @@ export default {
hasNoMatches() { hasNoMatches() {
return !this.hasGroupMatches && !this.hasNamespaceMatches; return !this.hasGroupMatches && !this.hasNamespaceMatches;
}, },
dropdownPlaceholderClass() {
return this.selectedNamespace.id ? '' : 'gl-text-gray-500!';
},
}, },
created() { created() {
eventHub.$on('select-template', this.handleSelectTemplate); eventHub.$on('select-template', this.handleSelectTemplate);
...@@ -130,11 +128,18 @@ export default { ...@@ -130,11 +128,18 @@ export default {
</script> </script>
<template> <template>
<gl-button-group class="input-lg"> <gl-button-group class="gl-w-full">
<gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button> <gl-button
class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!"
label
:title="rootUrl"
>{{ rootUrl }}</gl-button
>
<gl-dropdown <gl-dropdown
:text="selectedNamespace.fullPath" :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" data-qa-selector="select_namespace_dropdown"
@show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
@shown="handleDropdownShown" @shown="handleDropdownShown"
...@@ -166,11 +171,13 @@ export default { ...@@ -166,11 +171,13 @@ export default {
</template> </template>
</gl-dropdown> </gl-dropdown>
<input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" />
<input <input
id="project_namespace_id" id="project_namespace_id"
type="hidden" type="hidden"
name="project[namespace_id]" name="project[namespace_id]"
:value="selectedNamespace.id" :value="selectedNamespace.id || userNamespaceId"
/> />
</gl-button-group> </gl-button-group>
</template> </template>
...@@ -58,7 +58,6 @@ export function initNewProjectUrlSelect() { ...@@ -58,7 +58,6 @@ export function initNewProjectUrlSelect() {
namespaceId: el.dataset.namespaceId, namespaceId: el.dataset.namespaceId,
rootUrl: el.dataset.rootUrl, rootUrl: el.dataset.rootUrl,
trackLabel: el.dataset.trackLabel, trackLabel: el.dataset.trackLabel,
userNamespaceFullPath: el.dataset.userNamespaceFullPath,
userNamespaceId: el.dataset.userNamespaceId, userNamespaceId: el.dataset.userNamespaceId,
}, },
render: (createElement) => createElement(NewProjectUrlSelect), render: (createElement) => createElement(NewProjectUrlSelect),
......
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
let hasUserDefinedProjectPath = false; let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false; let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline'; const invalidInputClass = 'gl-field-error-outline';
const invalidDropdownClass = 'gl-inset-border-1-red-400!';
const cancelSource = axios.CancelToken.source(); const cancelSource = axios.CancelToken.source();
const endpoint = `${gon.relative_url_root}/import/url/validate`; const endpoint = `${gon.relative_url_root}/import/url/validate`;
...@@ -50,6 +51,25 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr ...@@ -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 setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
const specialRepo = document.querySelector('.js-user-readme-repo'); const specialRepo = document.querySelector('.js-user-readme-repo');
...@@ -70,6 +90,10 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { ...@@ -70,6 +90,10 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
$projectPathInput.val() !== $projectPathInput.data('username'), $projectPathInput.val() !== $projectPathInput.data('username'),
); );
}); });
document.querySelector('.js-create-project-button').addEventListener('click', (e) => {
validateGroupNamespaceDropdown(e);
});
}; };
const deriveProjectPathFromUrl = ($projectImportUrl) => { const deriveProjectPathFromUrl = ($projectImportUrl) => {
......
...@@ -69,6 +69,13 @@ class ProjectsController < Projects::ApplicationController ...@@ -69,6 +69,13 @@ class ProjectsController < Projects::ApplicationController
@namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id] @namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace) 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) @project = Project.new(namespace_id: @namespace&.id)
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
......
...@@ -9,27 +9,29 @@ ...@@ -9,27 +9,29 @@
= f.label :name, class: 'label-bold' do = f.label :name, class: 'label-bold' do
%span= _("Project name") %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 } = 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 = f.label :namespace_id, class: 'label-bold' do
%span= _('Project URL') %span= _('Project URL')
.input-group.gl-flex-nowrap .input-group.gl-flex-nowrap
- if current_user.can_select_namespace? - if current_user.can_select_namespace?
- namespace_id = namespace_id_from(params) - 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, .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, namespace_id: namespace_id || @current_user_group&.id,
root_url: root_url, root_url: root_url,
track_label: track_label, track_label: track_label,
user_namespace_full_path: current_user.namespace.full_path,
user_namespace_id: current_user.namespace.id } } user_namespace_id: current_user.namespace.id } }
- else - else
.input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' } .input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0 .input-group-text.border-0
#{user_url(current_user.username)}/ #{user_url(current_user.username)}/
= f.hidden_field :namespace_id, value: current_user.namespace_id = f.hidden_field :namespace_id, value: current_user.namespace_id
.gl-align-self-center.gl-pl-5 /
.form-group.project-path.col-sm-6 .form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do = f.label :path, class: 'label-bold' do
%span= _("Project slug") %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 } = 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? - if current_user.can_create_group?
.form-text.text-muted .form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path } - link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
...@@ -73,5 +75,5 @@ ...@@ -73,5 +75,5 @@
= s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') = 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' } = 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: "" } = 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 ...@@ -80,6 +80,9 @@ RSpec.describe 'New project', :js do
wait_for_requests 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_name', with: 'import-project-with-features1'
fill_in 'project_path', with: 'import-project-with-features1' fill_in 'project_path', with: 'import-project-with-features1'
choose 'project_visibility_level_20' choose 'project_visibility_level_20'
...@@ -103,6 +106,8 @@ RSpec.describe 'New project', :js do ...@@ -103,6 +106,8 @@ RSpec.describe 'New project', :js do
fill_in 'project_import_url', with: 'http://foo.git' fill_in 'project_import_url', with: 'http://foo.git'
fill_in 'project_name', with: 'CI CD Project1' fill_in 'project_name', with: 'CI CD Project1'
fill_in 'project_path', 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' choose 'project_visibility_level_20'
click_button 'Create project' click_button 'Create project'
......
...@@ -29722,6 +29722,12 @@ msgstr "" ...@@ -29722,6 +29722,12 @@ msgstr ""
msgid "ProjectsNew|No import options available" msgid "ProjectsNew|No import options available"
msgstr "" 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" msgid "ProjectsNew|Project Configuration"
msgstr "" msgstr ""
......
...@@ -90,7 +90,12 @@ module QA ...@@ -90,7 +90,12 @@ module QA
Page::Project::New.perform(&:click_blank_project_link) Page::Project::New.perform(&:click_blank_project_link)
Page::Project::New.perform do |new_page| 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.choose_name(@name)
new_page.add_description(@description) new_page.add_description(@description)
new_page.set_visibility(@visibility) new_page.set_visibility(@visibility)
......
...@@ -191,7 +191,8 @@ RSpec.describe 'New project', :js do ...@@ -191,7 +191,8 @@ RSpec.describe 'New project', :js do
click_link 'Create blank project' click_link 'Create blank project'
end 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 expect(page).to have_button user.username
end end
end end
...@@ -328,6 +329,14 @@ RSpec.describe 'New project', :js do ...@@ -328,6 +329,14 @@ RSpec.describe 'New project', :js do
click_on 'Create project' 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).to have_css('#import-project-pane.active')
expect(page).not_to have_css('.toggle-import-form.hide') expect(page).not_to have_css('.toggle-import-form.hide')
end end
......
...@@ -70,7 +70,7 @@ RSpec.describe 'User creates a project', :js do ...@@ -70,7 +70,7 @@ RSpec.describe 'User creates a project', :js do
fill_in :project_name, with: 'A Subgroup Project' fill_in :project_name, with: 'A Subgroup Project'
fill_in :project_path, 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 subgroup.full_path
click_button('Create project') click_button('Create project')
...@@ -97,9 +97,6 @@ RSpec.describe 'User creates a project', :js do ...@@ -97,9 +97,6 @@ RSpec.describe 'User creates a project', :js do
fill_in :project_name, with: 'a-new-project' fill_in :project_name, with: 'a-new-project'
fill_in :project_path, 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 page.within('#content-body') do
click_button('Create project') click_button('Create project')
end end
......
...@@ -15,6 +15,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -15,6 +15,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/projects/new/event_hub'; import eventHub from '~/projects/new/event_hub';
import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue'; 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 searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
import { s__ } from '~/locale';
describe('NewProjectUrlSelect component', () => { describe('NewProjectUrlSelect component', () => {
let wrapper; let wrapper;
...@@ -61,7 +62,6 @@ describe('NewProjectUrlSelect component', () => { ...@@ -61,7 +62,6 @@ describe('NewProjectUrlSelect component', () => {
namespaceId: '28', namespaceId: '28',
rootUrl: 'https://gitlab.com/', rootUrl: 'https://gitlab.com/',
trackLabel: 'blank_project', trackLabel: 'blank_project',
userNamespaceFullPath: 'root',
userNamespaceId: '1', userNamespaceId: '1',
}; };
...@@ -91,7 +91,10 @@ describe('NewProjectUrlSelect component', () => { ...@@ -91,7 +91,10 @@ describe('NewProjectUrlSelect component', () => {
const findButtonLabel = () => wrapper.findComponent(GlButton); const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInput = () => wrapper.findComponent(GlSearchBoxByType); 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 () => { const clickDropdownItem = async () => {
wrapper.findComponent(GlDropdownItem).vm.$emit('click'); wrapper.findComponent(GlDropdownItem).vm.$emit('click');
...@@ -122,11 +125,20 @@ describe('NewProjectUrlSelect component', () => { ...@@ -122,11 +125,20 @@ describe('NewProjectUrlSelect component', () => {
}); });
it('renders a dropdown with the given namespace full path as the text', () => { 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', () => { it('renders a hidden input with the selected namespace id', () => {
expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId); expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(
defaultProvide.namespaceId,
);
}); });
}); });
...@@ -142,11 +154,18 @@ describe('NewProjectUrlSelect component', () => { ...@@ -142,11 +154,18 @@ describe('NewProjectUrlSelect component', () => {
}); });
it("renders a dropdown with the user's namespace full path as the text", () => { 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", () => { it('renders a hidden input with the selected namespace id', () => {
expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId); expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(undefined);
}); });
}); });
...@@ -270,7 +289,7 @@ describe('NewProjectUrlSelect component', () => { ...@@ -270,7 +289,7 @@ describe('NewProjectUrlSelect component', () => {
await clickDropdownItem(); await clickDropdownItem();
expect(findHiddenInput().attributes('value')).toBe( expect(findHiddenNamespaceInput().attributes('value')).toBe(
getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), 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