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 @@ +<script> +import { + GlIcon, + GlLink, + GlForm, + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlButton, + GlFormRadio, + GlFormRadioGroup, + GlFormSelect, +} from '@gitlab/ui'; +import { buildApiUrl } from '~/api/api_utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import csrf from '~/lib/utils/csrf'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +const PRIVATE_VISIBILITY = 'private'; +const INTERNAL_VISIBILITY = 'internal'; +const PUBLIC_VISIBILITY = 'public'; + +const ALLOWED_VISIBILITY = { + private: [PRIVATE_VISIBILITY], + internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY], + public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], +}; + +export default { + components: { + GlForm, + GlIcon, + GlLink, + GlButton, + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlFormTextarea, + GlFormGroup, + GlFormRadio, + GlFormRadioGroup, + GlFormSelect, + }, + props: { + endpoint: { + type: String, + required: true, + }, + newGroupPath: { + type: String, + required: true, + }, + projectFullPath: { + type: String, + required: true, + }, + visibilityHelpPath: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + projectName: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectDescription: { + type: String, + required: true, + }, + projectVisibility: { + type: String, + required: true, + }, + }, + data() { + return { + isSaving: false, + namespaces: [], + selectedNamespace: {}, + fork: { + name: this.projectName, + slug: this.projectPath, + description: this.projectDescription, + visibility: this.projectVisibility, + }, + }; + }, + computed: { + projectUrl() { + return `${gon.gitlab_url}/`; + }, + projectAllowedVisibility() { + return ALLOWED_VISIBILITY[this.projectVisibility]; + }, + namespaceAllowedVisibility() { + return ( + ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || + ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] + ); + }, + visibilityLevels() { + return [ + { + text: s__('ForkProject|Private'), + value: PRIVATE_VISIBILITY, + icon: 'lock', + help: s__('ForkProject|The project can be accessed without any authentication.'), + disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY), + }, + { + text: s__('ForkProject|Internal'), + value: INTERNAL_VISIBILITY, + icon: 'shield', + help: s__('ForkProject|The project can be accessed by any logged in user.'), + disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY), + }, + { + text: s__('ForkProject|Public'), + value: PUBLIC_VISIBILITY, + icon: 'earth', + help: s__( + '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.', + ), + disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY), + }, + ]; + }, + }, + watch: { + selectedNamespace(newVal) { + const { visibility } = newVal; + + if (this.projectAllowedVisibility.includes(visibility)) { + this.fork.visibility = visibility; + } + }, + }, + mounted() { + this.fetchNamespaces(); + }, + methods: { + async fetchNamespaces() { + const { data } = await axios.get(this.endpoint); + this.namespaces = data.namespaces; + }, + isVisibilityLevelDisabled(visibilityLevel) { + return !( + this.projectAllowedVisibility.includes(visibilityLevel) && + this.namespaceAllowedVisibility.includes(visibilityLevel) + ); + }, + async onSubmit() { + this.isSaving = true; + + const { projectId } = this; + const { name, slug, description, visibility } = this.fork; + const { id: namespaceId } = this.selectedNamespace; + + const postParams = { + id: projectId, + name, + namespace_id: namespaceId, + path: slug, + description, + visibility, + }; + + const forkProjectPath = `/api/:version/projects/:id/fork`; + const url = buildApiUrl(forkProjectPath).replace(':id', encodeURIComponent(this.projectId)); + + try { + const { data } = await axios.post(url, postParams); + redirectTo(data.web_url); + return; + } catch (error) { + createFlash({ message: error }); + } + }, + }, + csrf, +}; +</script> + +<template> + <gl-form method="POST" @submit.prevent="onSubmit"> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + + <gl-form-group label="Project name" label-for="fork-name"> + <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> + </gl-form-group> + + <div class="gl-display-flex"> + <div class="gl-w-half"> + <gl-form-group label="Project URL" label-for="fork-url" class="gl-pr-2"> + <gl-form-input-group> + <template #prepend> + <gl-input-group-text> + {{ projectUrl }} + </gl-input-group-text> + </template> + <gl-form-select + id="fork-url" + v-model="selectedNamespace" + data-testid="fork-url-input" + required + > + <template slot="first"> + <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option> + </template> + <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace"> + {{ namespace.name }} + </option> + </gl-form-select> + </gl-form-input-group> + </gl-form-group> + </div> + <div class="gl-w-half"> + <gl-form-group label="Project slug" label-for="fork-slug" class="gl-pl-2"> + <gl-form-input + id="fork-slug" + v-model="fork.slug" + data-testid="fork-slug-input" + required + /> + </gl-form-group> + </div> + </div> + + <p class="gl-mt-n5 gl-text-gray-500"> + {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }} + <gl-link :href="newGroupPath" target="_blank"> + {{ s__('ForkProject|Create a group') }} + </gl-link> + </p> + + <gl-form-group label="Project description (optional)" label-for="fork-description"> + <gl-form-textarea + id="fork-description" + v-model="fork.description" + data-testid="fork-description-textarea" + /> + </gl-form-group> + + <gl-form-group> + <label> + {{ s__('ForkProject|Visibility level') }} + <gl-link :href="visibilityHelpPath" target="_blank"> + <gl-icon name="question-o" /> + </gl-link> + </label> + <gl-form-radio-group + v-model="fork.visibility" + data-testid="fork-visibility-radio-group" + required + > + <gl-form-radio + v-for="{ text, value, icon, help, disabled } in visibilityLevels" + :key="value" + :value="value" + :disabled="disabled" + :data-testid="`radio-${value}`" + > + <div> + <gl-icon :name="icon" /> + <span>{{ text }}</span> + </div> + <template #help>{{ help }}</template> + </gl-form-radio> + </gl-form-radio-group> + </gl-form-group> + + <div class="gl-display-flex gl-justify-content-space-between gl-mt-8"> + <gl-button + type="submit" + category="primary" + variant="confirm" + data-testid="submit-button" + :loading="isSaving" + > + {{ s__('ForkProject|Fork project') }} + </gl-button> + <gl-button + type="reset" + class="gl-mr-3" + data-testid="cancel-button" + :disabled="isSaving" + :href="projectFullPath" + > + {{ s__('ForkProject|Cancel') }} + </gl-button> + </div> + </gl-form> +</template> 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, + }); + }); + }); +});