diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..a59a8afa4de964b72700f11794b8e904d521b682 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + {{ projectUrl }} + + + + + {{ s__('ForkProject|Select a namespace') }} + + + {{ namespace.name }} + + + + + + + + + + + + + + {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }} + + {{ s__('ForkProject|Create a group') }} + + + + + + + + + + {{ s__('ForkProject|Visibility level') }} + + + + + + + + + {{ text }} + + {{ help }} + + + + + + + {{ s__('ForkProject|Fork project') }} + + + {{ s__('ForkProject|Cancel') }} + + + + diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index 26353aefe828cd0bdad272208f0aeb5cb33a28af..420639eefb7af6a779fdc2dcba27e190ce21dfc9 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,16 +1,53 @@ import Vue from 'vue'; +import ForkForm from './components/fork_form.vue'; import ForkGroupsList from './components/fork_groups_list.vue'; const mountElement = document.getElementById('fork-groups-mount-element'); -const { endpoint } = mountElement.dataset; -// eslint-disable-next-line no-new -new Vue({ - el: mountElement, - render(h) { - return h(ForkGroupsList, { - props: { - endpoint, - }, - }); - }, -}); + +if (gon.features.forkProjectForm) { + const { + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + } = mountElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: mountElement, + render(h) { + return h(ForkForm, { + props: { + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + }, + }); + }, + }); +} else { + const { endpoint } = mountElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: mountElement, + render(h) { + return h(ForkGroupsList, { + props: { + endpoint, + }, + }); + }, + }); +} diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 5576d5766c790584591d23db73607510e3b1e02a..33f046f414f1c05cbae79f80ab6147a225a8edeb 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -16,6 +16,10 @@ class Projects::ForksController < Projects::ApplicationController feature_category :source_code_management + before_action do + push_frontend_feature_flag(:fork_project_form) + end + def index @total_forks_count = project.forks.size @public_forks_count = project.forks.public_only.size diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index ccef28a2cf3050cf30a78c19aef7ced03525649b..562aa0fd353d7c8e7a7f93a9eb4eb77d44b1dce4 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -9,10 +9,20 @@ %br = _('Forking a repository allows you to make changes without affecting the original project.') .col-lg-9 - - if @own_namespace.present? - .fork-thumbnail-container.js-fork-content - %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 - = _("Select a namespace to fork the project") - = render 'fork_button', namespace: @own_namespace - #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } } - + - if Feature.enabled?(:fork_project_form) + #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json), + new_group_path: new_group_path, + project_full_path: project_path(@project), + visibility_help_path: help_page_path("public_access/public_access"), + project_id: @project.id, + project_name: @project.name, + project_path: @project.path, + project_description: @project.description, + project_visibility: @project.visibility } } + - else + - if @own_namespace.present? + .fork-thumbnail-container.js-fork-content + %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 + = _("Select a namespace to fork the project") + = render 'fork_button', namespace: @own_namespace + #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } } diff --git a/config/feature_flags/development/fork_project_form.yml b/config/feature_flags/development/fork_project_form.yml new file mode 100644 index 0000000000000000000000000000000000000000..93bccc4f41bbb675423265d13f82c51460eedd5f --- /dev/null +++ b/config/feature_flags/development/fork_project_form.yml @@ -0,0 +1,8 @@ +--- +name: fork_project_form +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53544 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321387 +milestone: '13.10' +type: development +group: group::source code +default_enabled: false diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bc98ce39dd72eac28d0026857b0bd2b9ae5610bb..8fc237e07eb5206a4160e077d4a657afe83b051b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13262,6 +13262,42 @@ msgstr "" msgid "Fork project?" msgstr "" +msgid "ForkProject|Cancel" +msgstr "" + +msgid "ForkProject|Create a group" +msgstr "" + +msgid "ForkProject|Fork project" +msgstr "" + +msgid "ForkProject|Internal" +msgstr "" + +msgid "ForkProject|Private" +msgstr "" + +msgid "ForkProject|Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group." +msgstr "" + +msgid "ForkProject|Public" +msgstr "" + +msgid "ForkProject|Select a namespace" +msgstr "" + +msgid "ForkProject|The project can be accessed by any logged in user." +msgstr "" + +msgid "ForkProject|The project can be accessed without any authentication." +msgstr "" + +msgid "ForkProject|Visibility level" +msgstr "" + +msgid "ForkProject|Want to house several dependent projects under the same namespace?" +msgstr "" + msgid "ForkedFromProjectPath|Forked from" msgstr "" diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 8d0500f5e1353357c7bbe1c32f8917cf0501f4dc..59bed5501f227b09c4dbcc82c4289399831a2b8e 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -9,6 +9,7 @@ let(:project) { create(:project, :public, :repository) } before do + stub_feature_flags(fork_project_form: false) sign_in(user) end diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5aafb1e8d2ec0675650f07b5030dddc662ad7d14 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -0,0 +1,273 @@ +import { GlForm, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import httpStatus from '~/lib/utils/http_status'; +import * as urlUtility from '~/lib/utils/url_utility'; +import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('ForkForm component', () => { + let wrapper; + let axiosMock; + + const GON_GITLAB_URL = 'https://gitlab.com'; + const GON_API_VERSION = 'v7'; + + const MOCK_NAMESPACES_RESPONSE = [ + { + name: 'one', + id: 1, + }, + { + name: 'two', + id: 2, + }, + ]; + + const DEFAULT_PROPS = { + endpoint: '/some/project-full-path/-/forks/new.json', + newGroupPath: 'some/groups/path', + projectFullPath: '/some/project-full-path', + visibilityHelpPath: 'some/visibility/help/path', + projectId: '10', + projectName: 'Project Name', + projectPath: 'project-name', + projectDescription: 'some project description', + projectVisibility: 'private', + }; + + const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { + axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); + }; + + const createComponent = (props = {}, data = {}) => { + wrapper = shallowMount(ForkForm, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFormInputGroup, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + window.gon = { + gitlab_url: GON_GITLAB_URL, + api_version: GON_API_VERSION, + }; + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); + const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); + const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); + const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]'); + const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]'); + const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]'); + const findForkDescriptionTextarea = () => + wrapper.find('[data-testid="fork-description-textarea"]'); + const findVisibilityRadioGroup = () => + wrapper.find('[data-testid="fork-visibility-radio-group"]'); + + it('will go to projectFullPath when click cancel button', () => { + mockGetRequest(); + createComponent(); + + const { projectFullPath } = DEFAULT_PROPS; + const cancelButton = wrapper.find('[data-testid="cancel-button"]'); + + expect(cancelButton.attributes('href')).toBe(projectFullPath); + }); + + it('make POST request with project param', async () => { + jest.spyOn(axios, 'post'); + + const namespaceId = 20; + + mockGetRequest(); + createComponent( + {}, + { + selectedNamespace: { + id: namespaceId, + }, + }, + ); + + wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} }); + + const { + projectId, + projectDescription, + projectName, + projectPath, + projectVisibility, + } = DEFAULT_PROPS; + + const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; + const project = { + description: projectDescription, + id: projectId, + name: projectName, + namespace_id: namespaceId, + path: projectPath, + visibility: projectVisibility, + }; + + expect(axios.post).toHaveBeenCalledWith(url, project); + }); + + it('has input with csrf token', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('pre-populate form from project props', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName); + expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath); + expect(findForkDescriptionTextarea().attributes('value')).toBe( + DEFAULT_PROPS.projectDescription, + ); + }); + + it('sets project URL prepend text with gon.gitlab_url', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`); + }); + + it('will have required attribute for required fields', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('required')).not.toBeUndefined(); + expect(findForkUrlInput().attributes('required')).not.toBeUndefined(); + expect(findForkSlugInput().attributes('required')).not.toBeUndefined(); + expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined(); + expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined(); + }); + + describe('forks namespaces', () => { + beforeEach(() => { + mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); + createComponent(); + }); + + it('make GET request from endpoint', async () => { + await axios.waitForAll(); + + expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); + }); + + it('generate default option', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray.at(0).text()).toBe('Select a namespace'); + }); + + it('populate project url namespace options', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); + expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].name); + expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].name); + }); + }); + + describe('visibility level', () => { + it.each` + project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} + `( + 'sets appropriate radio button disabled state', + async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { + mockGetRequest(); + createComponent( + { + projectVisibility: project, + }, + { + selectedNamespace: { + visibility: namespace, + }, + }, + ); + + expect(findPrivateRadio().attributes('disabled')).toBe(privateIsDisabled); + expect(findInternalRadio().attributes('disabled')).toBe(internalIsDisabled); + expect(findPublicRadio().attributes('disabled')).toBe(publicIsDisabled); + }, + ); + }); + + describe('onSubmit', () => { + beforeEach(() => { + jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); + }); + + it('redirect to POST web_url response', async () => { + const webUrl = `new/fork-project`; + + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + }); + + it('display flash when POST is unsuccessful', async () => { + const dummyError = 'Fork project failed'; + + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: dummyError, + }); + }); + }); +});
+ {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }} + + {{ s__('ForkProject|Create a group') }} + +