Commit 107b28be authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Brandon Labuschagne

Add the ability to cherry pick accross forks

Added the ability to cherry-pick accross forks
parent 4e4ca479
......@@ -7,7 +7,11 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { I18N_DROPDOWN } from '../constants';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_BRANCH_HEADER,
I18N_BRANCH_SEARCH_PLACEHOLDER,
} from '../constants';
export default {
name: 'BranchesDropdown',
......@@ -25,7 +29,11 @@ export default {
default: '',
},
},
i18n: I18N_DROPDOWN,
i18n: {
noResultsMessage: I18N_NO_RESULTS_MESSAGE,
branchHeaderTitle: I18N_BRANCH_HEADER,
branchSearchPlaceholder: I18N_BRANCH_SEARCH_PLACEHOLDER,
},
data() {
return {
searchTerm: this.value,
......@@ -41,6 +49,13 @@ export default {
);
},
},
watch: {
// Parent component can set the branch value (e.g. when the user selects a different project)
// and we need to keep the search term in sync with the selected value
value(val) {
this.searchTermChanged(val);
},
},
mounted() {
this.fetchBranches(this.searchTerm);
},
......@@ -61,13 +76,13 @@ export default {
};
</script>
<template>
<gl-dropdown :text="value" :header-text="$options.i18n.headerTitle">
<gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle">
<gl-search-box-by-type
:value="searchTerm"
trim
autocomplete="off"
:debounce="250"
:placeholder="$options.i18n.searchPlaceholder"
:placeholder="$options.i18n.branchSearchPlaceholder"
data-testid="dropdown-search-box"
@input="searchTermChanged"
/>
......
......@@ -3,18 +3,22 @@ import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab
import { mapActions, mapState } from 'vuex';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import csrf from '~/lib/utils/csrf';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import BranchesDropdown from './branches_dropdown.vue';
import ProjectsDropdown from './projects_dropdown.vue';
export default {
components: {
BranchesDropdown,
ProjectsDropdown,
GlModal,
GlForm,
GlFormCheckbox,
GlSprintf,
GlFormGroup,
},
mixins: [glFeatureFlagsMixin()],
inject: {
prependedText: {
default: '',
......@@ -60,13 +64,17 @@ export default {
'modalTitle',
'existingBranch',
'prependedText',
'targetProjectId',
'targetProjectName',
'branchesEndpoint',
]),
},
mounted() {
this.setSelectedProject(this.targetProjectId);
eventHub.$on(this.openModal, this.show);
},
methods: {
...mapActions(['clearModal', 'setBranch', 'setSelectedBranch']),
...mapActions(['clearModal', 'setBranch', 'setSelectedBranch', 'setSelectedProject']),
show() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
......@@ -101,6 +109,26 @@ export default {
<gl-form ref="form" :action="endpoint" method="post">
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-form-group
v-if="glFeatures.pickIntoProject"
:label="i18n.projectLabel"
label-for="start_project"
data-testid="dropdown-group"
>
<input
id="target_project_id"
type="hidden"
name="target_project_id"
:value="targetProjectId"
/>
<projects-dropdown
class="gl-w-half"
:value="targetProjectName"
@selectProject="setSelectedProject"
/>
</gl-form-group>
<gl-form-group
:label="i18n.branchLabel"
label-for="start_branch"
......
<script>
import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_PROJECT_HEADER,
I18N_PROJECT_SEARCH_PLACEHOLDER,
} from '../constants';
export default {
name: 'ProjectsDropdown',
components: {
GlDropdown,
GlSearchBoxByType,
GlDropdownItem,
GlDropdownText,
},
props: {
value: {
type: String,
required: false,
default: '',
},
},
i18n: {
noResultsMessage: I18N_NO_RESULTS_MESSAGE,
projectHeaderTitle: I18N_PROJECT_HEADER,
projectSearchPlaceholder: I18N_PROJECT_SEARCH_PLACEHOLDER,
},
data() {
return {
filterTerm: this.value,
};
},
computed: {
...mapGetters(['sortedProjects']),
...mapState(['targetProjectId']),
filteredResults() {
const lowerCasedFilterTerm = this.filterTerm.toLowerCase();
return this.sortedProjects.filter((project) =>
project.name.toLowerCase().includes(lowerCasedFilterTerm),
);
},
selectedProject() {
return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
},
},
methods: {
selectProject(project) {
this.$emit('selectProject', project.id);
this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project
},
isSelected(selectedProject) {
return selectedProject === this.selectedProject;
},
filterTermChanged(value) {
this.filterTerm = value;
},
},
};
</script>
<template>
<gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle">
<gl-search-box-by-type
:value="filterTerm"
trim
autocomplete="off"
:placeholder="$options.i18n.projectSearchPlaceholder"
data-testid="dropdown-search-box"
@input="filterTermChanged"
/>
<gl-dropdown-item
v-for="project in filteredResults"
:key="project.name"
:name="project.name"
:is-checked="isSelected(project)"
is-check-item
data-testid="dropdown-item"
@click="selectProject(project)"
>
{{ project.name }}
</gl-dropdown-item>
<gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message">
<span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
</gl-dropdown-text>
</gl-dropdown>
</template>
......@@ -26,6 +26,7 @@ export const I18N_REVERT_MODAL = {
export const I18N_CHERRY_PICK_MODAL = {
branchLabel: s__('ChangeTypeAction|Pick into branch'),
projectLabel: s__('ChangeTypeAction|Pick into project'),
actionPrimaryText: s__('ChangeTypeAction|Cherry-pick'),
};
......@@ -33,10 +34,12 @@ export const PREPENDED_MODAL_TEXT = s__(
'ChangeTypeAction|This will create a new commit in order to revert the existing changes.',
);
export const I18N_DROPDOWN = {
noResultsMessage: __('No matching results'),
headerTitle: s__('ChangeTypeAction|Switch branch'),
searchPlaceholder: s__('ChangeTypeAction|Search branches'),
};
export const I18N_NO_RESULTS_MESSAGE = __('No matching results');
export const I18N_PROJECT_HEADER = s__('ChangeTypeAction|Switch project');
export const I18N_PROJECT_SEARCH_PLACEHOLDER = s__('ChangeTypeAction|Search projects');
export const I18N_BRANCH_HEADER = s__('ChangeTypeAction|Switch branch');
export const I18N_BRANCH_SEARCH_PLACEHOLDER = s__('ChangeTypeAction|Search branches');
export const PROJECT_BRANCHES_ERROR = __('Something went wrong while fetching branches');
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import CommitFormModal from './components/form_modal.vue';
import {
I18N_MODAL,
......@@ -19,21 +19,27 @@ export default function initInviteMembersModal() {
title,
endpoint,
branch,
targetProjectId,
targetProjectName,
pushCode,
branchCollaboration,
existingBranch,
branchesEndpoint,
projects,
} = el.dataset;
const store = createStore({
endpoint,
branchesEndpoint,
branch,
targetProjectId,
targetProjectName,
pushCode: parseBoolean(pushCode),
branchCollaboration: parseBoolean(branchCollaboration),
defaultBranch: branch,
modalTitle: title,
existingBranch,
projects: convertObjectPropsToCamelCase(JSON.parse(projects), { deep: true }),
});
return new Vue({
......
......@@ -11,6 +11,10 @@ export const requestBranches = ({ commit }) => {
commit(types.REQUEST_BRANCHES);
};
export const setBranchesEndpoint = ({ commit }, endpoint) => {
commit(types.SET_BRANCHES_ENDPOINT, endpoint);
};
export const fetchBranches = ({ commit, dispatch, state }, query) => {
dispatch('requestBranches');
......@@ -18,8 +22,8 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => {
.get(state.branchesEndpoint, {
params: { search: query },
})
.then((res) => {
commit(types.RECEIVE_BRANCHES_SUCCESS, res.data);
.then(({ data }) => {
commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches || []);
})
.catch(() => {
createFlash({ message: PROJECT_BRANCHES_ERROR });
......@@ -34,3 +38,15 @@ export const setBranch = ({ commit, dispatch }, branch) => {
export const setSelectedBranch = ({ commit }, branch) => {
commit(types.SET_SELECTED_BRANCH, branch);
};
export const setSelectedProject = ({ commit, dispatch, state }, id) => {
let { branchesEndpoint } = state;
if (state.projects?.length) {
branchesEndpoint = state.projects.find((p) => p.id === id).refsUrl;
}
commit(types.SET_SELECTED_PROJECT, id);
dispatch('setBranchesEndpoint', branchesEndpoint);
dispatch('fetchBranches');
};
......@@ -3,3 +3,5 @@ import { uniq } from 'lodash';
export const joinedBranches = (state) => {
return uniq(state.branches).sort();
};
export const sortedProjects = (state) => uniq(state.projects).sort();
export const CLEAR_MODAL = 'CLEAR_MODAL';
export const SET_BRANCHES_ENDPOINT = 'SET_BRANCHES_ENDPOINT';
export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
export const SET_BRANCH = 'SET_BRANCH';
export const SET_SELECTED_BRANCH = 'SET_SELECTED_BRANCH';
export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
import * as types from './mutation_types';
export default {
[types.SET_BRANCHES_ENDPOINT](state, endpoint) {
state.branchesEndpoint = endpoint;
},
[types.REQUEST_BRANCHES](state) {
state.isFetching = true;
},
......@@ -22,4 +26,9 @@ export default {
[types.SET_SELECTED_BRANCH](state, branch) {
state.selectedBranch = branch;
},
[types.SET_SELECTED_PROJECT](state, projectId) {
state.targetProjectId = projectId;
state.branch = state.defaultBranch;
},
};
......@@ -3,6 +3,7 @@ export default () => ({
branchesEndpoint: null,
isFetching: false,
branches: [],
projects: [],
selectedBranch: '',
pushCode: false,
branchCollaboration: false,
......@@ -10,4 +11,6 @@ export default () => ({
existingBranch: '',
defaultBranch: '',
branch: '',
targetProjectId: '',
targetProjectName: '',
});
......@@ -24,6 +24,10 @@ class Projects::CommitController < Projects::ApplicationController
push_frontend_feature_flag(:ci_commit_pipeline_mini_graph_vue, @project, default_enabled: :yaml)
end
before_action do
push_frontend_feature_flag(:pick_into_project)
end
BRANCH_SEARCH_LIMIT = 1000
COMMIT_DIFFS_PER_PAGE = 75
......
......@@ -134,6 +134,16 @@ module CommitsHelper
end
end
def cherry_pick_projects_data(project)
target_projects(project).map do |project|
{
id: project.id.to_s,
name: project.full_path,
refsUrl: refs_project_path(project)
}
end
end
protected
# Private: Returns a link to a person. If the person has a matching user and
......
......@@ -18,7 +18,10 @@
.js-cherry-pick-commit-modal{ data: { title: title,
endpoint: cherry_pick_namespace_project_commit_path(commit, namespace_id: @project.namespace.full_path, project_id: @project),
branch: @project.default_branch,
target_project_id: @project.id,
target_project_name: @project.full_path,
push_code: can?(current_user, :push_code, @project).to_s,
branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s,
existing_branch: ERB::Util.html_escape(selected_branch),
branches_endpoint: project_branches_path(@project) } }
branches_endpoint: refs_project_path(@project),
projects: cherry_pick_projects_data(@project).to_json } }
---
title: Add the ability to cherry pick accross forks
merge_request: 55970
author:
type: added
---
name: pick_into_project
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55970
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/324154
milestone: '13.10'
type: development
group: group::source code
default_enabled: false
......@@ -5642,6 +5642,9 @@ msgstr ""
msgid "ChangeTypeAction|Pick into branch"
msgstr ""
msgid "ChangeTypeAction|Pick into project"
msgstr ""
msgid "ChangeTypeAction|Revert"
msgstr ""
......@@ -5651,12 +5654,18 @@ msgstr ""
msgid "ChangeTypeAction|Search branches"
msgstr ""
msgid "ChangeTypeAction|Search projects"
msgstr ""
msgid "ChangeTypeAction|Start a %{newMergeRequest} with these changes"
msgstr ""
msgid "ChangeTypeAction|Switch branch"
msgstr ""
msgid "ChangeTypeAction|Switch project"
msgstr ""
msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
msgstr ""
......
......@@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import BranchesDropdown from '~/projects/commit/components/branches_dropdown.vue';
import CommitFormModal from '~/projects/commit/components/form_modal.vue';
import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue';
import eventHub from '~/projects/commit/event_hub';
import createStore from '~/projects/commit/store';
import mockData from '../mock_data';
......@@ -20,7 +21,10 @@ describe('CommitFormModal', () => {
store = createStore({ ...mockData.mockModal, ...state });
wrapper = extendedWrapper(
method(CommitFormModal, {
provide,
provide: {
...provide,
glFeatures: { pickIntoProject: true },
},
propsData: { ...mockData.modalPropsData },
store,
attrs: {
......@@ -33,7 +37,9 @@ describe('CommitFormModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findStartBranch = () => wrapper.find('#start_branch');
const findDropdown = () => wrapper.findComponent(BranchesDropdown);
const findTargetProject = () => wrapper.find('#target_project_id');
const findBranchesDropdown = () => wrapper.findComponent(BranchesDropdown);
const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdown);
const findForm = () => findModal().findComponent(GlForm);
const findCheckBox = () => findForm().findComponent(GlFormCheckbox);
const findPrependedText = () => wrapper.findByTestId('prepended-text');
......@@ -146,11 +152,19 @@ describe('CommitFormModal', () => {
});
it('Changes the start_branch input value', async () => {
findDropdown().vm.$emit('selectBranch', '_changed_branch_value_');
findBranchesDropdown().vm.$emit('selectBranch', '_changed_branch_value_');
await wrapper.vm.$nextTick();
expect(findStartBranch().attributes('value')).toBe('_changed_branch_value_');
});
it('Changes the target_project_id input value', async () => {
findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_');
await wrapper.vm.$nextTick();
expect(findTargetProject().attributes('value')).toBe('_changed_project_value_');
});
});
});
import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue';
Vue.use(Vuex);
describe('ProjectsDropdown', () => {
let wrapper;
let store;
const spyFetchProjects = jest.fn();
const projectsMockData = [
{ id: '1', name: '_project_1_', refsUrl: '_project_1_/refs' },
{ id: '2', name: '_project_2_', refsUrl: '_project_2_/refs' },
{ id: '3', name: '_project_3_', refsUrl: '_project_3_/refs' },
];
const createComponent = (term, state = {}) => {
store = new Vuex.Store({
getters: {
sortedProjects: () => projectsMockData,
},
state,
});
wrapper = extendedWrapper(
shallowMount(ProjectsDropdown, {
store,
propsData: {
value: term,
},
}),
);
};
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
const findNoResults = () => wrapper.findByTestId('empty-result-message');
afterEach(() => {
wrapper.destroy();
spyFetchProjects.mockReset();
});
describe('No projects found', () => {
beforeEach(() => {
createComponent('_non_existent_project_');
});
it('renders empty results message', () => {
expect(findNoResults().text()).toBe('No matching results');
});
it('shows GlSearchBoxByType with default attributes', () => {
expect(findSearchBoxByType().exists()).toBe(true);
expect(findSearchBoxByType().vm.$attrs).toMatchObject({
placeholder: 'Search projects',
});
});
});
describe('Search term is empty', () => {
beforeEach(() => {
createComponent('');
});
it('renders all projects when search term is empty', () => {
expect(findAllDropdownItems()).toHaveLength(3);
expect(findDropdownItemByIndex(0).text()).toBe('_project_1_');
expect(findDropdownItemByIndex(1).text()).toBe('_project_2_');
expect(findDropdownItemByIndex(2).text()).toBe('_project_3_');
});
it('should not be selected on the inactive project', () => {
expect(wrapper.vm.isSelected('_project_1_')).toBe(false);
});
});
describe('Projects found', () => {
beforeEach(() => {
createComponent('_project_1_', { targetProjectId: '1' });
});
it('renders only the project searched for', () => {
expect(findAllDropdownItems()).toHaveLength(1);
expect(findDropdownItemByIndex(0).text()).toBe('_project_1_');
});
it('should not display empty results message', () => {
expect(findNoResults().exists()).toBe(false);
});
it('should signify this project is selected', () => {
expect(findDropdownItemByIndex(0).props('isChecked')).toBe(true);
});
it('should signify the project is not selected', () => {
expect(wrapper.vm.isSelected('_not_selected_project_')).toBe(false);
});
describe('Custom events', () => {
it('should emit selectProject if a project is clicked', () => {
findDropdownItemByIndex(0).vm.$emit('click');
expect(wrapper.emitted('selectProject')).toEqual([['1']]);
expect(wrapper.vm.filterTerm).toBe('_project_1_');
});
});
});
describe('Case insensitive for search term', () => {
beforeEach(() => {
createComponent('_PrOjEcT_1_');
});
it('renders only the project searched for', () => {
expect(findAllDropdownItems()).toHaveLength(1);
expect(findDropdownItemByIndex(0).text()).toBe('_project_1_');
});
});
});
......@@ -24,4 +24,5 @@ export default {
openModal: '_open_modal_',
},
mockBranches: ['_branch_1', '_abc_', '_master_'],
mockProjects: ['_project_1', '_abc_', '_project_'],
};
......@@ -47,7 +47,7 @@ describe('Commit form modal store actions', () => {
it('dispatch correct actions on fetchBranches', (done) => {
jest
.spyOn(axios, 'get')
.mockImplementation(() => Promise.resolve({ data: mockData.mockBranches }));
.mockImplementation(() => Promise.resolve({ data: { Branches: mockData.mockBranches } }));
testAction(
actions.fetchBranches,
......@@ -108,4 +108,43 @@ describe('Commit form modal store actions', () => {
]);
});
});
describe('setBranchesEndpoint', () => {
it('commits SET_BRANCHES_ENDPOINT mutation', () => {
const endpoint = 'some/endpoint';
testAction(actions.setBranchesEndpoint, endpoint, {}, [
{
type: types.SET_BRANCHES_ENDPOINT,
payload: endpoint,
},
]);
});
});
describe('setSelectedProject', () => {
const id = 1;
it('commits SET_SELECTED_PROJECT mutation', () => {
testAction(
actions.setSelectedProject,
id,
{},
[
{
type: types.SET_SELECTED_PROJECT,
payload: id,
},
],
[
{
type: 'setBranchesEndpoint',
},
{
type: 'fetchBranches',
},
],
);
});
});
});
......@@ -18,4 +18,21 @@ describe('Commit form modal getters', () => {
expect(getters.joinedBranches(state)).toEqual(branches.slice(1));
});
});
describe('sortedProjects', () => {
it('should sort projects with variable branches', () => {
const state = {
projects: mockData.mockProjects,
};
expect(getters.sortedProjects(state)).toEqual(mockData.mockProjects.sort());
});
it('should provide a uniq list of projects', () => {
const projects = ['_project_', '_project_', '_some_other_project'];
const state = { projects };
expect(getters.sortedProjects(state)).toEqual(projects.slice(1));
});
});
});
......@@ -35,6 +35,16 @@ describe('Commit form modal mutations', () => {
});
});
describe('SET_BRANCHES_ENDPOINT', () => {
it('should set branchesEndpoint', () => {
stateCopy = { branchesEndpoint: 'endpoint/1' };
mutations[types.SET_BRANCHES_ENDPOINT](stateCopy, 'endpoint/2');
expect(stateCopy.branchesEndpoint).toBe('endpoint/2');
});
});
describe('SET_BRANCH', () => {
it('should set branch', () => {
stateCopy = { branch: '_master_' };
......@@ -54,4 +64,14 @@ describe('Commit form modal mutations', () => {
expect(stateCopy.selectedBranch).toBe('_changed_branch_');
});
});
describe('SET_SELECTED_PROJECT', () => {
it('should set targetProjectId', () => {
stateCopy = { targetProjectId: '_project_1_' };
mutations[types.SET_SELECTED_PROJECT](stateCopy, '_project_2_');
expect(stateCopy.targetProjectId).toBe('_project_2_');
});
});
});
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe CommitsHelper do
include ProjectForksHelper
describe '#revert_commit_link' do
context 'when current_user exists' do
before do
......@@ -239,4 +241,21 @@ RSpec.describe CommitsHelper do
end
end
end
describe '#cherry_pick_projects_data' do
let(:project) { create(:project, :repository) }
let(:user) { create(:user, maintainer_projects: [project]) }
let!(:forked_project) { fork_project(project, user, { namespace: user.namespace, repository: true }) }
before do
allow(helper).to receive(:current_user).and_return(user)
end
it 'returns data for cherry picking into a project' do
expect(helper.cherry_pick_projects_data(project)).to match_array([
{ id: project.id.to_s, name: project.full_path, refsUrl: refs_project_path(project) },
{ id: forked_project.id.to_s, name: forked_project.full_path, refsUrl: refs_project_path(forked_project) }
])
end
end
end
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