diff --git a/app/assets/javascripts/import/gitlab_project/import_from_gitlab_export_app.vue b/app/assets/javascripts/import/gitlab_project/import_from_gitlab_export_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..4c261a59377a5a8a68b22dd933e1ae6c273f84e9 --- /dev/null +++ b/app/assets/javascripts/import/gitlab_project/import_from_gitlab_export_app.vue @@ -0,0 +1,292 @@ + + + diff --git a/app/assets/javascripts/import/gitlab_project/index.js b/app/assets/javascripts/import/gitlab_project/index.js new file mode 100644 index 0000000000000000000000000000000000000000..0954f3d929f5a116630d04d1e31e08f98b97605c --- /dev/null +++ b/app/assets/javascripts/import/gitlab_project/index.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import importFromGitlabExportApp from './import_from_gitlab_export_app.vue'; + +export function initGitLabImportProjectForm() { + const el = document.getElementById('js-import-gitlab-project-root'); + + if (!el) { + return null; + } + + const { + backButtonPath, + namespaceFullPath, + namespaceId, + rootPath, + importGitlabProjectPath, + userNamespaceId, + canCreateProject, + rootUrl, + } = el.dataset; + + const props = { + backButtonPath, + namespaceFullPath, + namespaceId, + rootPath, + importGitlabProjectPath, + }; + + const provide = { + userNamespaceId, + canCreateProject, + rootUrl, + }; + + return new Vue({ + el, + name: 'ImportGitLabProjectRoot', + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), + provide, + render(h) { + return h(importFromGitlabExportApp, { props }); + }, + }); +} diff --git a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js index d0560af5b3f5de48ad01bd0e22975f0d450d9bbf..fe872bfcfc08fabc5e158485e8d4b6803e962313 100644 --- a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js +++ b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js @@ -1,5 +1,7 @@ import initGitLabImportProject from '~/projects/project_import_gitlab_project'; import { initNewProjectUrlSelect } from '~/projects/new'; +import { initGitLabImportProjectForm } from '~/import/gitlab_project'; initNewProjectUrlSelect(); initGitLabImportProject(); +initGitLabImportProjectForm(); diff --git a/app/assets/javascripts/projects/new_v2/components/project_destination_select.vue b/app/assets/javascripts/projects/new_v2/components/project_destination_select.vue index 65f7f6f516475261f5b929d515c148181df364fe..7675e1c9ed3facb5f7f3e43d779703765029a5ea 100644 --- a/app/assets/javascripts/projects/new_v2/components/project_destination_select.vue +++ b/app/assets/javascripts/projects/new_v2/components/project_destination_select.vue @@ -1,19 +1,17 @@ diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js index 0cbd4dbf2cfd060c88f7cc6134d4756deb29ea29..d7f710b555888760cdaa3fedcc467004e046cc73 100644 --- a/app/assets/javascripts/projects/project_import_gitlab_project.js +++ b/app/assets/javascripts/projects/project_import_gitlab_project.js @@ -25,20 +25,22 @@ export default () => { const $projectPath = document.querySelector('.js-path-name'); const { name, path } = prepareParameters(); - // get the project name from the URL and set it as input value - $projectName.value = name; - - // get the path url and append it in the input - $projectPath.value = path; - - // generate slug when project name changes - $projectName.addEventListener('keyup', () => { - projectNew.onProjectNameChange($projectName, $projectPath); - hasUserDefinedProjectName = $projectName.value.trim().length > 0; - }); - - // generate project name from the slug if one isn't set - $projectPath.addEventListener('keyup', () => - projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName), - ); + if ($projectName || $projectPath) { + // get the project name from the URL and set it as input value + $projectName.value = name; + + // get the path url and append it in the input + $projectPath.value = path; + + // generate slug when project name changes + $projectName.addEventListener('keyup', () => { + projectNew.onProjectNameChange($projectName, $projectPath); + hasUserDefinedProjectName = $projectName.value.trim().length > 0; + }); + + // generate project name from the slug if one isn't set + $projectPath.addEventListener('keyup', () => + projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName), + ); + } }; diff --git a/app/assets/stylesheets/page_bundles/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss index 7cd0ae0e16cf35083f67011c9b8baa6035163a3b..ce095e6d5f98a0b479f3f6a73e251cf9c0a55fe0 100644 --- a/app/assets/stylesheets/page_bundles/projects.scss +++ b/app/assets/stylesheets/page_bundles/projects.scss @@ -557,3 +557,8 @@ @apply gl-line-clamp-2 gl-whitespace-normal; margin-bottom: 0; } + +// stylelint-disable-next-line gitlab/no-gl-class +.project-destination-select .gl-button-text { + flex-grow: 1; +} diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 52f008ae10b4aae686acb3964a51dbe3bf0971ef..55ddfa25a110f061f30b20fa8998d039b517bcfb 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -2,27 +2,40 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -= render ::Layouts::PageHeadingComponent.new('') do |c| - - c.with_heading do - %span.gl-inline-flex.gl-items-center.gl-gap-3 - = sprite_icon('tanuki', size: 32) - = _('Import an exported GitLab project') +- if Feature.enabled?(:new_project_creation_form, @user) + - add_page_specific_style 'page_bundles/projects' + - namespace_id = namespace_id_from(params) + #js-import-gitlab-project-root{ data: { + back_button_path: new_project_path(anchor: 'import_project'), + namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path || current_user.namespace.full_path, + namespace_id: namespace_id_from(params) || @current_user_group&.id, + import_gitlab_project_path: import_gitlab_project_path, + root_path: root_path, + user_namespace_id: current_user.namespace_id, + can_create_project: current_user.can_create_project?.to_s, + root_url: root_url, + } } +- else + = render ::Layouts::PageHeadingComponent.new('') do |c| + - c.with_heading do + %span.gl-inline-flex.gl-items-center.gl-gap-3 + = sprite_icon('tanuki', size: 32) + = _('Import an exported GitLab project') -= form_tag import_gitlab_project_path, class: 'new_project', multipart: true do - = render 'import/shared/new_project_form' - - .row - .form-group.col-md-12 - = _("To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.") - .row - .form-group.col-sm-12 - = label_tag :file, _('GitLab project export'), class: 'label-bold' - .form-group - = file_field_tag :file, class: '' - .row - .col-sm-12.gl-mt-5 - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'gl-mr-2', data: { testid: 'import-project-button' }}) do - = _('Import project') - = render Pajamas::ButtonComponent.new(href: new_project_path) do - = _('Cancel') + = form_tag import_gitlab_project_path, class: 'new_project', multipart: true do + = render 'import/shared/new_project_form' + .row + .form-group.col-md-12 + = _("To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.") + .row + .form-group.col-sm-12 + = label_tag :file, _('GitLab project export'), class: 'label-bold' + .form-group + = file_field_tag :file, class: '' + .row + .col-sm-12.gl-mt-5 + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'gl-mr-2', data: { testid: 'import-project-button' }}) do + = _('Import project') + = render Pajamas::ButtonComponent.new(href: new_project_path) do + = _('Cancel') diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2c695a6addca99a722931922fd3c58af441b2622..66110f9c846081ba6776a9d6048e5d575973caaf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21384,6 +21384,9 @@ msgstr "" msgid "Drop or %{linkStart}upload%{linkEnd} files to attach" msgstr "" +msgid "Drop or upload file to attach" +msgstr "" + msgid "Drop your designs to start your upload." msgstr "" @@ -46048,6 +46051,9 @@ msgstr "" msgid "ProjectsNew|Get started with one of our popular project templates." msgstr "" +msgid "ProjectsNew|GitLab project export" +msgstr "" + msgid "ProjectsNew|Gitea host URL" msgstr "" @@ -46087,6 +46093,9 @@ msgstr "" msgid "ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces." msgstr "" +msgid "ProjectsNew|My awesome project" +msgstr "" + msgid "ProjectsNew|New project" msgstr "" @@ -46105,6 +46114,15 @@ msgstr "" msgid "ProjectsNew|Please enter a valid personal access token." msgstr "" +msgid "ProjectsNew|Please enter a valid project name." +msgstr "" + +msgid "ProjectsNew|Please enter a valid project slug." +msgstr "" + +msgid "ProjectsNew|Please upload a valid GitLab project export file." +msgstr "" + msgid "ProjectsNew|Project Configuration" msgstr "" @@ -46114,6 +46132,9 @@ msgstr "" msgid "ProjectsNew|Project name" msgstr "" +msgid "ProjectsNew|Project slug" +msgstr "" + msgid "ProjectsNew|Projects" msgstr "" @@ -46132,6 +46153,9 @@ msgstr "" msgid "ProjectsNew|Select a template" msgstr "" +msgid "ProjectsNew|To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here." +msgstr "" + msgid "ProjectsNew|Unable to suggest a path. Please refresh and try again." msgstr "" @@ -46156,6 +46180,9 @@ msgstr "" msgid "ProjectsNew|https://mycompany.fogbugz.com" msgstr "" +msgid "ProjectsNew|my-awesome-project" +msgstr "" + msgid "Projects|An error occurred deleting the project. Please refresh the page to try again." msgstr "" diff --git a/spec/frontend/import/gitlab_project/import_from_gitlab_export_app_spec.js b/spec/frontend/import/gitlab_project/import_from_gitlab_export_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e1d251288c20991ee85230938a585959af9c38fc --- /dev/null +++ b/spec/frontend/import/gitlab_project/import_from_gitlab_export_app_spec.js @@ -0,0 +1,164 @@ +import { nextTick } from 'vue'; +import { GlAnimatedUploadIcon, GlFormInput } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import importFromGitlabExportApp from '~/import/gitlab_project/import_from_gitlab_export_app.vue'; +import MultiStepFormTemplate from '~/vue_shared/components/multi_step_form_template.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('Import from GitLab export file app', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(importFromGitlabExportApp, { + propsData: { + backButtonPath: '/projects/new#import_project', + namespaceFullPath: 'root', + namespaceId: '1', + rootPath: '/', + importGitlabProjectPath: 'import/path', + }, + stubs: { + GlFormInput, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + const findMultiStepForm = () => wrapper.findComponent(MultiStepFormTemplate); + const findForm = () => wrapper.find('form'); + const findProjectNameInput = () => wrapper.findByTestId('project-name'); + const findProjectSlugInput = () => wrapper.findByTestId('project-slug'); + const findDropzoneButton = () => wrapper.findByTestId('dropzone-button'); + const findDropzoneInput = () => wrapper.findByTestId('dropzone-input'); + const findAnimatedUploadIcon = () => wrapper.findComponent(GlAnimatedUploadIcon); + const findBackButton = () => wrapper.findByTestId('back-button'); + const findNextButton = () => wrapper.findByTestId('next-button'); + + const setProjectName = async (projectName) => { + await findProjectNameInput().setValue(projectName); + await findProjectNameInput().trigger('blur'); + + await nextTick(); + }; + + const uploadFile = async () => { + const file = new File(['foo'], 'foo.gz', { type: 'application/gzip', size: 1024 }); + Object.defineProperty(findDropzoneInput().element, 'files', { value: [file] }); + findDropzoneInput().trigger('change'); + + await nextTick(); + }; + + describe('form', () => { + it('renders the multi step form correctly', () => { + expect(findMultiStepForm().props()).toMatchObject({ + currentStep: 3, + stepsTotal: 3, + }); + }); + + it('renders the form element correctly', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe('import/path'); + expect(form.find('input[type=hidden][name=authenticity_token]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('does not submit the form without requried fields', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + findForm().trigger('submit'); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('submits the form with valid form data', async () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + await setProjectName('test project'); + uploadFile(); + await nextTick(); + + findForm().trigger('submit'); + + expect(submitSpy).toHaveBeenCalledWith(); + }); + }); + + describe('validation', () => { + it('shows an error message when project name is cleared', async () => { + await setProjectName(''); + + const formGroup = wrapper.findByTestId('project-name-form-group'); + expect(formGroup.vm.$attrs['invalid-feedback']).toBe('Please enter a valid project name.'); + }); + + it('shows an error message when project name starts with invalid characters', async () => { + await setProjectName('#test'); + + const formGroup = wrapper.findByTestId('project-name-form-group'); + expect(formGroup.vm.$attrs['invalid-feedback']).toBe( + 'Name must start with a letter, digit, emoji, or underscore.', + ); + }); + + it('shows an error message when project name contains invalid characters', async () => { + await setProjectName('test?'); + + const formGroup = wrapper.findByTestId('project-name-form-group'); + expect(formGroup.vm.$attrs['invalid-feedback']).toBe( + 'Name can contain only lowercase or uppercase letters, digits, emoji, spaces, dots, underscores, dashes, or pluses.', + ); + }); + + it('shows an error message when there are no file uploaded', async () => { + findForm().trigger('submit'); + await nextTick(); + + const formGroup = wrapper.findByTestId('project-file-form-group'); + expect(formGroup.vm.$attrs['invalid-feedback']).toBe( + 'Please upload a valid GitLab project export file.', + ); + }); + }); + + describe('project slug', () => { + it('updates the project slug appropriately when updating project name', async () => { + await setProjectName('test project'); + + expect(findProjectSlugInput().props('value')).toBe('test-project'); + }); + }); + + describe('drop zone', () => { + it('renders a drop zone', () => { + expect(findDropzoneInput().exists()).toBe(true); + expect(findDropzoneButton().text()).toBe('Drop or upload file to attach'); + expect(findAnimatedUploadIcon().exists()).toBe(true); + }); + + it('uploads a file', async () => { + await uploadFile(); + + expect(findDropzoneButton().text()).toContain('foo.gz'); + expect(findAnimatedUploadIcon().exists()).toBe(false); + }); + }); + + describe('back button', () => { + it('renders a back button', () => { + expect(findBackButton().attributes('href')).toBe('/projects/new#import_project'); + }); + }); + + describe('next button', () => { + it('renders a next button', () => { + expect(findNextButton().attributes('type')).toBe('submit'); + }); + }); +}); diff --git a/spec/views/import/gitlab_projects/new.html.haml_spec.rb b/spec/views/import/gitlab_projects/new.html.haml_spec.rb index 68e7019c892a51bfba88c04f4a72d32f703d7778..af3be5009fd507de3af2f88bfc4191bcd86c1af7 100644 --- a/spec/views/import/gitlab_projects/new.html.haml_spec.rb +++ b/spec/views/import/gitlab_projects/new.html.haml_spec.rb @@ -9,6 +9,7 @@ let(:user) { build_stubbed(:user, namespace: namespace) } before do + stub_feature_flags(new_project_creation_form: false) allow(view).to receive(:current_user).and_return(user) end