Commit c4f39e68 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '15013-fork-project-form-vue' into 'master'

Add fork project form in vue [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!53544
parents f13d0250 070d04b5
<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>
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({
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, {
......@@ -13,4 +49,5 @@ new Vue({
},
});
},
});
});
}
......@@ -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
......
......@@ -9,10 +9,20 @@
%br
= _('Forking a repository allows you to make changes without affecting the original project.')
.col-lg-9
- 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) } }
---
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
......@@ -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 ""
......
......@@ -9,6 +9,7 @@ RSpec.describe 'Project fork' do
let(:project) { create(:project, :public, :repository) }
before do
stub_feature_flags(fork_project_form: false)
sign_in(user)
end
......
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,
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment