diff --git a/app/assets/javascripts/projects/new_v2/components/app.vue b/app/assets/javascripts/projects/new_v2/components/app.vue index 3a9a386d1084ea20966668d579bb5a9ffd98428a..6ee636d86bf5e033917ecc1db4ce5b7a40da467a 100644 --- a/app/assets/javascripts/projects/new_v2/components/app.vue +++ b/app/assets/javascripts/projects/new_v2/components/app.vue @@ -76,9 +76,9 @@ export default { data() { return { namespace: { - id: this.namespaceId, - fullPath: this.namespaceFullPath, - isPersonal: this.namespaceId === '', + id: this.namespaceId ? this.namespaceId : this.userNamespaceId, + fullPath: this.namespaceFullPath ? this.namespaceFullPath : this.userNamespaceFullPath, + isPersonal: this.namespaceId ? this.userNamespaceId === this.namespaceId : true, }, selectedProjectType: OPTIONS.blank.value, currentStep: 1, diff --git a/app/assets/javascripts/projects/new_v2/components/blank_project_form.vue b/app/assets/javascripts/projects/new_v2/components/blank_project_form.vue index 3378b253374b320e3721f559578f2afc5cf379b6..3d37ba7c9177a1692bc70ce50e3e33220360cda8 100644 --- a/app/assets/javascripts/projects/new_v2/components/blank_project_form.vue +++ b/app/assets/javascripts/projects/new_v2/components/blank_project_form.vue @@ -1,5 +1,6 @@ - - - + + + + + - - - - - {{ + + + {{ + s__( + 'ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.', + ) + }} + + + + + + + + + {{ content }} + + + + + + + - - - - - - - - {{ content }} - - - + " + data-testid="initialize-with-sha-256-checkbox" + > + {{ s__('ProjectsNew|Use SHA-256 for repository hashing algorithm') }} + - - - - {{ s__('ProjectsNew|Use SHA-256 for repository hashing algorithm') }} - - - - - + + + - - - - - {{ __('Create project') }} - - - - - {{ __('Go back') }} - - - + + + + + {{ __('Create project') }} + + + + + {{ __('Go back') }} + + + + diff --git a/app/assets/javascripts/projects/new_v2/components/project_name_validator.vue b/app/assets/javascripts/projects/new_v2/components/project_name_validator.vue new file mode 100644 index 0000000000000000000000000000000000000000..064c339963291054909911c990ef6089d78cd681 --- /dev/null +++ b/app/assets/javascripts/projects/new_v2/components/project_name_validator.vue @@ -0,0 +1,92 @@ + + + + + {{ alertMessage }} + + diff --git a/app/assets/javascripts/projects/new_v2/components/shared_project_creation_fields.vue b/app/assets/javascripts/projects/new_v2/components/shared_project_creation_fields.vue index ceda513e33ead0bec55b05f47c9ac4a40c4760e6..83a216c12c2b225e10c43d7c5e29b9862ebcfcbc 100644 --- a/app/assets/javascripts/projects/new_v2/components/shared_project_creation_fields.vue +++ b/app/assets/javascripts/projects/new_v2/components/shared_project_creation_fields.vue @@ -13,11 +13,11 @@ import { VISIBILITY_LEVEL_PUBLIC_STRING, VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_LEVEL_DESCRIPTIONS, - VISIBILITY_LEVELS_INTEGER_TO_STRING, VISIBILITY_LEVELS_STRING_TO_INTEGER, } from '~/visibility_level/constants'; import { K8S_OPTION, DEPLOYMENT_TARGET_SELECTIONS } from '../form_constants'; import NewProjectDestinationSelect from './project_destination_select.vue'; +import ProjectNameValidator from './project_name_validator.vue'; export default { components: { @@ -28,6 +28,7 @@ export default { GlLink, GlIcon, NewProjectDestinationSelect, + ProjectNameValidator, SingleChoiceSelector, SingleChoiceSelectorItem, }, @@ -67,9 +68,6 @@ export default { isK8sOptionSelected() { return this.selectedTarget === K8S_OPTION.value; }, - defaultVisibilityLevel() { - return VISIBILITY_LEVELS_INTEGER_TO_STRING[this.defaultProjectVisibility]; - }, }, mounted() { this.setVisibilityLevelsOptions(); @@ -98,7 +96,8 @@ export default { 'VisibilityLevel|This visibility level has been restricted by your administrator.', ); } else if ( - visibilityLevelInteger > VISIBILITY_LEVELS_STRING_TO_INTEGER[this.namespace.visibility] + visibilityLevelInteger > + VISIBILITY_LEVELS_STRING_TO_INTEGER[this.selectedNamespace.visibility] ) { disableMessage = s__( 'VisibilityLevel|This visibility level is not allowed because the parent group has a more restrictive visibility level.', @@ -109,12 +108,16 @@ export default { title: VISIBILITY_LEVEL_LABELS[visibilityLevelString], description: PROJECT_VISIBILITY_LEVEL_DESCRIPTIONS[visibilityLevelString], icon: VISIBILITY_TYPE_ICON[visibilityLevelString], - value: visibilityLevelString, + value: String(visibilityLevelInteger), + name: visibilityLevelString, disabled: disableMessage !== '', disabledMessage: disableMessage, id: 1, }; }, + updateProjectNameValidationStatus(status) { + this.$emit('onValidateForm', status && this.form.state); + }, }, helpPageK8s: helpPagePath('user/clusters/agent/_index'), DEPLOYMENT_TARGET_SELECTIONS, @@ -194,6 +197,13 @@ export default { + + - + {{ title }} diff --git a/app/assets/javascripts/projects/new_v2/index.js b/app/assets/javascripts/projects/new_v2/index.js index 0238f3190731102396137b605856906e92de8096..4cbd724ad531c4eb8a739ed735b6ac77aaec86ce 100644 --- a/app/assets/javascripts/projects/new_v2/index.js +++ b/app/assets/javascripts/projects/new_v2/index.js @@ -51,6 +51,7 @@ export function initNewProjectForm() { importManifestEnabled, importManifestImportPath, importByUrlValidatePath, + formPath, } = el.dataset; const provide = { @@ -93,6 +94,7 @@ export function initNewProjectForm() { importManifestEnabled: parseBoolean(importManifestEnabled), importManifestImportPath, importByUrlValidatePath, + formPath, }; return new Vue({ diff --git a/app/assets/javascripts/projects/new_v2/queries/search_project_name_availability.query.graphql b/app/assets/javascripts/projects/new_v2/queries/search_project_name_availability.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..7e3ef5f326702b3e5592efe815daad39a6b1adaf --- /dev/null +++ b/app/assets/javascripts/projects/new_v2/queries/search_project_name_availability.query.graphql @@ -0,0 +1,12 @@ +query searchProjectNameAvailability($namespacePath: ID!, $search: String) { + namespace(fullPath: $namespacePath) { + id + projects(search: $search) { + nodes { + id + path + name + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/single_choice_selector.vue b/app/assets/javascripts/vue_shared/components/single_choice_selector.vue index 9d7716a794c20276b577edcd4fd62552bf2f7b9d..062417c8483d5e2ffc2a9863b9ffa35bab838591 100644 --- a/app/assets/javascripts/vue_shared/components/single_choice_selector.vue +++ b/app/assets/javascripts/vue_shared/components/single_choice_selector.vue @@ -9,6 +9,11 @@ export default { required: false, default: undefined, }, + name: { + type: String, + required: false, + default: undefined, + }, }, methods: { onChange(value) { @@ -21,6 +26,7 @@ export default { diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 617d1410283e99d31198d378d8c123ce3a0c6507..d5880068e2bad203e117bf69d60be8784f59d335 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -45,7 +45,8 @@ import_git_enabled: git_import_enabled?.to_s, import_manifest_enabled: manifest_import_enabled?.to_s, import_manifest_import_path: new_import_manifest_path(namespace_id: @namespace&.id), - import_by_url_validate_path: validate_import_url_path } } + import_by_url_validate_path: validate_import_url_path, + form_path: "/projects" } } - else .project-edit-container .project-edit-errors diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6631589bb77eb9c71d26a81c4bd9de05a1adc998..72823c72ce654576d7bf239565cecd72108bf4e2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -51614,6 +51614,9 @@ msgstr "" msgid "ProjectsNew|Bitbucket Server URL" msgstr "" +msgid "ProjectsNew|Both project name and project path are already taken. Please choose different values for both." +msgstr "" + msgid "ProjectsNew|Choose a group" msgstr "" @@ -51779,6 +51782,12 @@ msgstr "" msgid "ProjectsNew|Project name" msgstr "" +msgid "ProjectsNew|Project name is already taken. Please choose a different project name." +msgstr "" + +msgid "ProjectsNew|Project path is already taken. Please choose a different project path." +msgstr "" + msgid "ProjectsNew|Project slug" msgstr "" diff --git a/spec/frontend/projects/new_v2/components/blank_project_form_spec.js b/spec/frontend/projects/new_v2/components/blank_project_form_spec.js index 513af51ce8f44dbb7b240ebfc6de2526f4c717bb..d3c81178f4f71e496991a65b3944a55cba0fa9fc 100644 --- a/spec/frontend/projects/new_v2/components/blank_project_form_spec.js +++ b/spec/frontend/projects/new_v2/components/blank_project_form_spec.js @@ -3,6 +3,8 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BlankProjectForm from '~/projects/new_v2/components/blank_project_form.vue'; import SharedProjectCreationFields from '~/projects/new_v2/components/shared_project_creation_fields.vue'; +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + describe('Blank Project Form', () => { let wrapper; @@ -24,6 +26,7 @@ describe('Blank Project Form', () => { ...props, }, provide: { + formPath: '/projects', ...provide, }, }); @@ -33,9 +36,9 @@ describe('Blank Project Form', () => { createComponent(); }); + const findForm = () => wrapper.find('form'); const findMultiStepFormTemplate = () => wrapper.findComponent(MultiStepFormTemplate); const findSharedProjectCreationFields = () => wrapper.findComponent(SharedProjectCreationFields); - const findCreateButton = () => wrapper.findByTestId('create-project-button'); const findBackButton = () => wrapper.findByTestId('create-project-back-button'); it('passes the correct props to MultiStepFormTemplate', () => { @@ -50,9 +53,36 @@ describe('Blank Project Form', () => { it('renders the SharedProjectCreationFields component', () => { expect(findSharedProjectCreationFields().exists()).toBe(true); }); + + it('renders the form element correctly', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe('/projects'); + expect(form.find('input[type=hidden][name=authenticity_token]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('does not submit the form without required fields', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + findSharedProjectCreationFields().vm.$emit('onValidateForm', false); + + findForm().trigger('submit'); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('submit the form correctly', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + findSharedProjectCreationFields().vm.$emit('onValidateForm', true); + + findForm().trigger('submit'); + expect(submitSpy).toHaveBeenCalled(); + }); }); - describe('configuraqtion block', () => { + describe('configuration block', () => { it('renders the block', () => { expect(wrapper.findByTestId('configuration-form-group').exists()).toBe(true); }); @@ -71,11 +101,6 @@ describe('Blank Project Form', () => { }); }); - it('renders the option to Create Project as disabled', () => { - expect(findCreateButton().text()).toBe('Create project'); - expect(findCreateButton().props('disabled')).toBe(true); - }); - it(`emits the "back" event when the back button is clicked`, () => { findBackButton().vm.$emit('click'); expect(wrapper.emitted('back')).toHaveLength(1); diff --git a/spec/frontend/projects/new_v2/components/project_name_validator_spec.js b/spec/frontend/projects/new_v2/components/project_name_validator_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..25362a2b9e3a1f6e5bdaa6d7a6883c19478b3263 --- /dev/null +++ b/spec/frontend/projects/new_v2/components/project_name_validator_spec.js @@ -0,0 +1,100 @@ +import { GlAlert } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ProjectNameValidator from '~/projects/new_v2/components/project_name_validator.vue'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchProjectNameAvailabilityQuery from '~/projects/new_v2/queries/search_project_name_availability.query.graphql'; + +Vue.use(VueApollo); + +describe('Project name availability alert', () => { + let wrapper; + + const defaultHandler = [ + searchProjectNameAvailabilityQuery, + jest.fn().mockResolvedValue({ + data: { + namespace: { + id: 'gid://gitlab/Group/1', + projects: { + nodes: [ + { + id: '1', + name: 'Test 1', + path: 'test-1', + }, + { + id: '2', + name: 'Test 2', + path: 'test-2', + }, + ], + }, + }, + }, + }), + ]; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ProjectNameValidator, { + apolloProvider: createMockApollo([defaultHandler]), + propsData: { + namespaceFullPath: 'namespace-full-path', + ...props, + }, + }); + + return waitForPromises(); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const waitForQuery = async () => { + jest.advanceTimersByTime(DEBOUNCE_DELAY); + await waitForPromises(); + }; + + it('does not render an alert if project name and path are not defined', () => { + createComponent(); + + expect(findAlert().exists()).toBe(false); + }); + + describe('renders an alert', () => { + it('when name exists', async () => { + await createComponent({ projectName: 'Test 1' }); + await waitForQuery(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toContain('name'); + expect(findAlert().text()).not.toContain('path'); + }); + + it('when path exists', async () => { + await createComponent({ projectPath: 'test-2' }); + await waitForQuery(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toContain('path'); + expect(findAlert().text()).not.toContain('name'); + }); + + it('when both path and exist', async () => { + await createComponent({ projectName: 'Test 1', projectPath: 'test-1' }); + await waitForQuery(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toContain('path'); + expect(findAlert().text()).toContain('name'); + }); + }); +}); diff --git a/spec/frontend/projects/new_v2/components/shared_project_creation_fields_spec.js b/spec/frontend/projects/new_v2/components/shared_project_creation_fields_spec.js index de339f8cc604343795a287846a925aca5efdd1ba..2b21c058030cc291e6c74b76562653e01f4327c5 100644 --- a/spec/frontend/projects/new_v2/components/shared_project_creation_fields_spec.js +++ b/spec/frontend/projects/new_v2/components/shared_project_creation_fields_spec.js @@ -4,6 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SingleChoiceSelector from '~/vue_shared/components/single_choice_selector.vue'; import SharedProjectCreationFields from '~/projects/new_v2/components/shared_project_creation_fields.vue'; import NewProjectDestinationSelect from '~/projects/new_v2/components/project_destination_select.vue'; +import ProjectNameValidator from '~/projects/new_v2/components/project_name_validator.vue'; import { DEPLOYMENT_TARGET_SELECTIONS } from '~/projects/new_v2/form_constants'; describe('Project creation form fields component', () => { @@ -39,6 +40,7 @@ describe('Project creation form fields component', () => { const findDeploymentTargetSelect = () => wrapper.findByTestId('deployment-target-select'); const findKubernetesHelpLink = () => wrapper.findByTestId('kubernetes-help-link'); const findVisibilitySelector = () => wrapper.findComponent(SingleChoiceSelector); + const findProjectNameValidator = () => wrapper.findComponent(ProjectNameValidator); const findPrivateVisibilityLevelOption = () => wrapper.findByTestId('private-visibility-level'); const findInternalVisibilityLevelOption = () => wrapper.findByTestId('internal-visibility-level'); const findPublicVisibilityLevelOption = () => wrapper.findByTestId('public-visibility-level'); @@ -110,6 +112,43 @@ describe('Project creation form fields component', () => { const formGroup = wrapper.findByTestId('project-slug-group'); expect(formGroup.vm.$attrs['invalid-feedback']).toBe('Please enter project slug.'); }); + + it('renders a project name availability alert', async () => { + createComponent(); + + findProjectNameInput().setValue('Test Name'); + findProjectNameInput().trigger('blur'); + findProjectSlugInput().setValue('test-name'); + findProjectSlugInput().trigger('blur'); + + await nextTick(); + + expect(findProjectNameValidator().exists()).toBe(true); + expect(findProjectNameValidator().props()).toEqual({ + namespaceFullPath: 'root', + projectPath: 'test-name', + projectName: 'Test Name', + }); + }); + + it('emits onValidateForm when project name validation status changes', () => { + createComponent(); + + findProjectNameInput().setValue('Test Name'); + findProjectNameInput().trigger('blur'); + findProjectSlugInput().setValue('test-name'); + findProjectSlugInput().trigger('blur'); + findProjectNameValidator().vm.$emit('onValidation', true); + + expect(wrapper.emitted('onValidateForm')[0][0]).toEqual(true); + }); + + it('emits onValidateForm with false when project name validation fails', () => { + createComponent(); + findProjectNameValidator().vm.$emit('onValidation', false); + + expect(wrapper.emitted('onValidateForm')[0][0]).toEqual(false); + }); }); describe('visibility selector', () => { @@ -156,7 +195,7 @@ describe('Project creation form fields component', () => { provide: { defaultProjectVisibility: 10 }, }); - expect(findVisibilitySelector().props('checked')).toBe('internal'); + expect(findVisibilitySelector().props('checked')).toBe(10); }); }); }); diff --git a/spec/frontend/vue_shared/components/single_choice_selector_spec.js b/spec/frontend/vue_shared/components/single_choice_selector_spec.js index c0075a23d6e4eb29e4f48f8b795110a633d410ce..239c19927cb0fec4cef0cefe8563f0a299fbe8ca 100644 --- a/spec/frontend/vue_shared/components/single_choice_selector_spec.js +++ b/spec/frontend/vue_shared/components/single_choice_selector_spec.js @@ -26,4 +26,10 @@ describe('SingleChoice', () => { createComponent({ checked: checkedValue }); expect(findRadioGroup().attributes('checked')).toBe(checkedValue); }); + + it('passes the name prop to GlFormRadioGroup', () => { + const radioGroupName = 'test-name'; + createComponent({ checked: 'test-option', name: radioGroupName }); + expect(findRadioGroup().attributes('name')).toBe(radioGroupName); + }); });
+ + + {{ content }} + + +
- - - {{ content }} - - -