From 3e8a1d1fa6f6441503ae8a1705e6cfa1f26c7b78 Mon Sep 17 00:00:00 2001 From: Julia Miocene Date: Mon, 14 Jul 2025 14:51:55 +0200 Subject: [PATCH 1/6] Enable submission of blank project creation form --- .../projects/new_v2/components/app.vue | 6 +- .../new_v2/components/blank_project_form.vue | 202 +++++++++--------- .../project_name_availability_alert.vue | 105 +++++++++ .../shared_project_creation_fields.vue | 19 +- .../javascripts/projects/new_v2/index.js | 2 + ...ch_project_name_availability.query.graphql | 12 ++ app/views/projects/new.html.haml | 3 +- locale/gitlab.pot | 12 ++ .../components/blank_project_form_spec.js | 25 ++- .../project_name_availability_alert_spec.js | 94 ++++++++ 10 files changed, 373 insertions(+), 107 deletions(-) create mode 100644 app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue create mode 100644 app/assets/javascripts/projects/new_v2/queries/search_project_name_availability.query.graphql create mode 100644 spec/frontend/projects/new_v2/components/project_name_availability_alert_spec.js diff --git a/app/assets/javascripts/projects/new_v2/components/app.vue b/app/assets/javascripts/projects/new_v2/components/app.vue index 3a9a386d1084ea..6ee636d86bf5e0 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 3378b253374b32..d45421b9a37345 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 @@ diff --git a/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue b/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue new file mode 100644 index 00000000000000..25a40cc1b485a0 --- /dev/null +++ b/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue @@ -0,0 +1,105 @@ + + + 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 ceda513e33ead0..72e68179c99829 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 @@ -18,6 +18,7 @@ import { } from '~/visibility_level/constants'; import { K8S_OPTION, DEPLOYMENT_TARGET_SELECTIONS } from '../form_constants'; import NewProjectDestinationSelect from './project_destination_select.vue'; +import ProjectNameAvailabilityAlert from './project_name_availability_alert.vue'; export default { components: { @@ -28,6 +29,7 @@ export default { GlLink, GlIcon, NewProjectDestinationSelect, + ProjectNameAvailabilityAlert, SingleChoiceSelector, SingleChoiceSelectorItem, }, @@ -61,6 +63,7 @@ export default { selectedNamespace: this.namespace, selectedTarget: null, visibilityLevels: [], + selectedVisibility: null, }; }, computed: { @@ -98,7 +101,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.', @@ -115,6 +119,10 @@ export default { id: 1, }; }, + onChangeVisibility(value) { + this.selectedVisibility = + VISIBILITY_LEVELS_STRING_TO_INTEGER[value] || this.defaultProjectVisibility; + }, }, helpPageK8s: helpPagePath('user/clusters/agent/_index'), DEPLOYMENT_TARGET_SELECTIONS, @@ -194,6 +202,12 @@ export default { + + - + diff --git a/app/assets/javascripts/projects/new_v2/index.js b/app/assets/javascripts/projects/new_v2/index.js index 0238f319073110..4cbd724ad531c4 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 00000000000000..7e3ef5f326702b --- /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/views/projects/new.html.haml b/app/views/projects/new.html.haml index 617d1410283e99..d5880068e2bad2 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 6631589bb77eb9..d2156b8dd98a88 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -51770,6 +51770,9 @@ msgstr "" msgid "ProjectsNew|Please upload a valid GitLab project export file." msgstr "" +msgid "ProjectsNew|Project %{object} has already been taken. Please use another %{object}." +msgstr "" + msgid "ProjectsNew|Project Configuration" msgstr "" @@ -78173,6 +78176,12 @@ msgstr "" msgid "n/a" msgstr "" +msgid "name" +msgstr "" + +msgid "name and path" +msgstr "" + msgid "name: %{name} exists in more than one image pull secret, image pull secrets must have a unique 'name'" msgstr "" @@ -78289,6 +78298,9 @@ msgstr "" msgid "past due" msgstr "" +msgid "path" +msgstr "" + msgid "pending comment" 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 513af51ce8f44d..448ec8721d2819 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,6 +36,7 @@ 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'); @@ -50,6 +54,22 @@ 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(findCreateButton().element, 'click'); + + findForm().trigger('submit'); + expect(submitSpy).not.toHaveBeenCalled(); + }); }); describe('configuraqtion block', () => { @@ -71,11 +91,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_availability_alert_spec.js b/spec/frontend/projects/new_v2/components/project_name_availability_alert_spec.js new file mode 100644 index 00000000000000..2beb6f2fe39d5c --- /dev/null +++ b/spec/frontend/projects/new_v2/components/project_name_availability_alert_spec.js @@ -0,0 +1,94 @@ +import { GlAlert, GlSprintf } 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 ProjectNameAvailabilityAlert from '~/projects/new_v2/components/project_name_availability_alert.vue'; +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(ProjectNameAvailabilityAlert, { + apolloProvider: createMockApollo([defaultHandler]), + propsData: { + namespaceFullPath: 'namespace-full-path', + ...props, + }, + stubs: { + GlSprintf, + }, + }); + + return waitForPromises(); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + 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 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 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 waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toContain('path'); + expect(findAlert().text()).toContain('name'); + }); + }); +}); -- GitLab From 6a0e2eaced2a55fa935e4314442973f2219fd772 Mon Sep 17 00:00:00 2001 From: Julia Miocene Date: Mon, 21 Jul 2025 11:36:16 +0200 Subject: [PATCH 2/6] Apply suggestions --- .../project_name_availability_alert.vue | 22 +++++-------------- .../shared_project_creation_fields_spec.js | 7 ++++++ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue b/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue index 25a40cc1b485a0..f851a7dff341b4 100644 --- a/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue +++ b/app/assets/javascripts/projects/new_v2/components/project_name_availability_alert.vue @@ -67,24 +67,14 @@ export default { }, methods: { checkPathAvailability() { - if (this.projectPath) { - const projectsWithTheSamePath = this.namespace.find( - (project) => project.path === this.projectPath, - ); - if (projectsWithTheSamePath) { - this.isProjectPathExist = true; - } else this.isProjectPathExist = false; - } else this.isProjectPathExist = false; + this.isProjectPathExist = Boolean( + this.projectPath && this.namespace.find((project) => project.path === this.projectPath), + ); }, checkNameAvailability() { - if (this.projectName) { - const projectsWithTheSameName = this.namespace.find( - (project) => project.name === this.projectName, - ); - if (projectsWithTheSameName) { - this.isProjectNameExist = true; - } else this.isProjectNameExist = false; - } else this.isProjectNameExist = false; + this.isProjectNameExist = Boolean( + this.projectName && this.namespace.find((project) => project.name === this.projectName), + ); }, }, }; 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 de339f8cc60434..1483f08c6a9a27 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 ProjectNameAvailabilityAlert from '~/projects/new_v2/components/project_name_availability_alert.vue'; import { DEPLOYMENT_TARGET_SELECTIONS } from '~/projects/new_v2/form_constants'; describe('Project creation form fields component', () => { @@ -110,6 +111,12 @@ 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', () => { + createComponent(); + + expect(wrapper.findComponent(ProjectNameAvailabilityAlert).exists()).toBe(true); + }); }); describe('visibility selector', () => { -- GitLab From f3a7bb53f3c477a8b3f7a59023054dca7a1936cc Mon Sep 17 00:00:00 2001 From: Julia Miocene Date: Thu, 24 Jul 2025 14:33:01 +0200 Subject: [PATCH 3/6] Apply suggestions --- .../new_v2/components/blank_project_form.vue | 13 ++++- .../project_name_availability_alert.vue | 52 ++++++++++--------- .../shared_project_creation_fields.vue | 20 +++---- .../components/single_choice_selector.vue | 6 +++ locale/gitlab.pot | 21 ++++---- .../project_name_availability_alert_spec.js | 14 +++-- .../shared_project_creation_fields_spec.js | 2 +- 7 files changed, 72 insertions(+), 56 deletions(-) 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 d45421b9a37345..3d37ba7c9177a1 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 @@ -43,6 +43,7 @@ export default { configurationReadme: true, configurationSast: false, configurationSha256: false, + isFormValid: false, }; }, methods: { @@ -54,8 +55,14 @@ export default { this.configurationSast = checked.includes('sast'); this.configurationSha256 = checked.includes('sha256'); }, + onValidateSharedFields(status) { + this.isFormValid = status; + }, onSubmit() { - this.$refs.form.submit(); + if (this.isFormValid) { + this.$refs.form.submit(); + } + return false; }, }, helpPageSast: helpPagePath('user/application_security/sast/_index'), @@ -65,13 +72,14 @@ export default {