From 69baab0f79bc9cbe6cbf679dca810042fa76872c Mon Sep 17 00:00:00 2001 From: Samantha Ming Date: Fri, 12 Feb 2021 06:53:18 -0800 Subject: [PATCH 1/2] Add fork project form Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/15013 --- .../forks/new/components/fork_form.vue | 304 ++++++++++++++++++ .../pages/projects/forks/new/index.js | 61 +++- app/controllers/projects/forks_controller.rb | 4 + app/views/projects/forks/new.html.haml | 24 +- .../development/fork_project_form.yml | 8 + locale/gitlab.pot | 36 +++ spec/features/projects/fork_spec.rb | 1 + .../forks/new/components/fork_form_spec.js | 277 ++++++++++++++++ 8 files changed, 696 insertions(+), 19 deletions(-) create mode 100644 app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue create mode 100644 config/feature_flags/development/fork_project_form.yml create mode 100644 spec/frontend/pages/projects/forks/new/components/fork_form_spec.js 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 00000000000000..a59a8afa4de964 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -0,0 +1,304 @@ + + + diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index 26353aefe828cd..420639eefb7af6 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 5576d5766c7905..33f046f414f1c0 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 ccef28a2cf3050..562aa0fd353d7c 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 00000000000000..93bccc4f41bbb6 --- /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 bc98ce39dd72ea..8fc237e07eb520 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 8d0500f5e13533..59bed5501f227b 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 00000000000000..eb44bdd9853104 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -0,0 +1,277 @@ +import { GlForm, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +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 = {}) => { + wrapper = shallowMount(ForkForm, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + 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'); + + mockGetRequest(); + createComponent(); + + const namespaceId = 20; + + wrapper.setData({ + 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.length).toBe(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, + }); + + wrapper.setData({ + selectedNamespace: { + visibility: namespace, + }, + }); + + await nextTick(); + + 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').mockImplementation(() => { + return Promise.resolve({ + 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').mockImplementation(() => { + return Promise.reject(dummyError); + }); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: dummyError, + }); + }); + }); +}); -- GitLab From 2c29d73f6b4156e4a731efba1ab1b469f129c6d9 Mon Sep 17 00:00:00 2001 From: Samantha Ming Date: Tue, 2 Mar 2021 20:23:27 -0800 Subject: [PATCH 2/2] Address reviewer comment - Pass data into factory - Other minor refactoring --- .../forks/new/components/fork_form_spec.js | 56 +++++++++---------- 1 file changed, 26 insertions(+), 30 deletions(-) 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 index eb44bdd9853104..5aafb1e8d2ec06 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -2,7 +2,6 @@ import { GlForm, GlFormInputGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import createFlash from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import * as urlUtility from '~/lib/utils/url_utility'; @@ -45,12 +44,17 @@ describe('ForkForm component', () => { axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); }; - const createComponent = (props = {}) => { + const createComponent = (props = {}, data = {}) => { wrapper = shallowMount(ForkForm, { propsData: { ...DEFAULT_PROPS, ...props, }, + data() { + return { + ...data, + }; + }, stubs: { GlFormInputGroup, }, @@ -94,16 +98,17 @@ describe('ForkForm component', () => { it('make POST request with project param', async () => { jest.spyOn(axios, 'post'); - mockGetRequest(); - createComponent(); - const namespaceId = 20; - wrapper.setData({ - selectedNamespace: { - id: namespaceId, + mockGetRequest(); + createComponent( + {}, + { + selectedNamespace: { + id: namespaceId, + }, }, - }); + ); wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} }); @@ -191,7 +196,7 @@ describe('ForkForm component', () => { const optionsArray = findForkUrlInput().findAll('option'); - expect(optionsArray.length).toBe(MOCK_NAMESPACES_RESPONSE.length + 1); + 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); }); @@ -213,17 +218,16 @@ describe('ForkForm component', () => { 'sets appropriate radio button disabled state', async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { mockGetRequest(); - createComponent({ - projectVisibility: project, - }); - - wrapper.setData({ - selectedNamespace: { - visibility: namespace, + createComponent( + { + projectVisibility: project, }, - }); - - await nextTick(); + { + selectedNamespace: { + visibility: namespace, + }, + }, + ); expect(findPrivateRadio().attributes('disabled')).toBe(privateIsDisabled); expect(findInternalRadio().attributes('disabled')).toBe(internalIsDisabled); @@ -240,13 +244,7 @@ describe('ForkForm component', () => { it('redirect to POST web_url response', async () => { const webUrl = `new/fork-project`; - jest.spyOn(axios, 'post').mockImplementation(() => { - return Promise.resolve({ - data: { - web_url: webUrl, - }, - }); - }); + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); mockGetRequest(); createComponent(); @@ -259,9 +257,7 @@ describe('ForkForm component', () => { it('display flash when POST is unsuccessful', async () => { const dummyError = 'Fork project failed'; - jest.spyOn(axios, 'post').mockImplementation(() => { - return Promise.reject(dummyError); - }); + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); mockGetRequest(); createComponent(); -- GitLab