Commit 75c036f4 authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Phil Hughes

Add ability to swap revisions

parent 2c4abf28
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import { joinPaths } from '~/lib/utils/url_utility';
import RevisionCard from './revision_card.vue'; import RevisionCard from './revision_card.vue';
export default { export default {
...@@ -36,11 +37,46 @@ export default { ...@@ -36,11 +37,46 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
defaultProject: {
type: Object,
required: true,
},
projects: {
type: Array,
required: true,
},
},
data() {
return {
from: {
projects: this.projects,
selectedProject: this.defaultProject,
revision: this.paramsFrom,
refsProjectPath: this.refsProjectPath,
},
to: {
selectedProject: this.defaultProject,
revision: this.paramsTo,
refsProjectPath: this.refsProjectPath,
},
};
}, },
methods: { methods: {
onSubmit() { onSubmit() {
this.$refs.form.submit(); this.$refs.form.submit();
}, },
onSelectProject({ direction, project }) {
const refsPath = joinPaths(gon.relative_url_root || '', `/${project.name}`, '/refs');
// direction is either 'from' or 'to'
this[direction].refsProjectPath = refsPath;
this[direction].selectedProject = project;
},
onSelectRevision({ direction, revision }) {
this[direction].revision = revision; // direction is either 'from' or 'to'
},
onSwapRevision() {
[this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to'
},
}, },
}; };
</script> </script>
...@@ -57,10 +93,15 @@ export default { ...@@ -57,10 +93,15 @@ export default {
class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards" class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards"
> >
<revision-card <revision-card
:refs-project-path="refsProjectPath" data-testid="sourceRevisionCard"
:refs-project-path="to.refsProjectPath"
revision-text="Source" revision-text="Source"
params-name="to" params-name="to"
:params-branch="paramsTo" :params-branch="to.revision"
:projects="to.projects"
:selected-project="to.selectedProject"
@selectProject="onSelectProject"
@selectRevision="onSelectRevision"
/> />
<div <div
class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0" class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0"
...@@ -69,16 +110,24 @@ export default { ...@@ -69,16 +110,24 @@ export default {
... ...
</div> </div>
<revision-card <revision-card
:refs-project-path="refsProjectPath" data-testid="targetRevisionCard"
:refs-project-path="from.refsProjectPath"
revision-text="Target" revision-text="Target"
params-name="from" params-name="from"
:params-branch="paramsFrom" :params-branch="from.revision"
:projects="from.projects"
:selected-project="from.selectedProject"
@selectProject="onSelectProject"
@selectRevision="onSelectRevision"
/> />
</div> </div>
<div class="gl-mt-4"> <div class="gl-mt-4">
<gl-button category="primary" variant="success" @click="onSubmit"> <gl-button category="primary" variant="success" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }} {{ s__('CompareRevisions|Compare') }}
</gl-button> </gl-button>
<gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision">
{{ s__('CompareRevisions|Swap revisions') }}
</gl-button>
<gl-button <gl-button
v-if="projectMergeRequestPath" v-if="projectMergeRequestPath"
:href="projectMergeRequestPath" :href="projectMergeRequestPath"
......
<script> <script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
const SOURCE_PARAM_NAME = 'to';
export default { export default {
components: { components: {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlSearchBoxByType, GlSearchBoxByType,
}, },
inject: ['projectTo', 'projectsFrom'],
props: { props: {
paramsName: { paramsName: {
type: String, type: String,
required: true, required: true,
}, },
projects: {
type: Array,
required: false,
default: null,
},
selectedProject: {
type: Object,
required: true,
},
}, },
data() { data() {
return { return {
searchTerm: '', searchTerm: '',
selectedRepo: {},
}; };
}, },
computed: { computed: {
disableRepoDropdown() {
return this.projects === null;
},
filteredRepos() { filteredRepos() {
const lowerCaseSearchTerm = this.searchTerm.toLowerCase(); const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
return this?.projectsFrom.filter(({ name }) => return this?.projects.filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm));
name.toLowerCase().includes(lowerCaseSearchTerm),
);
},
isSourceRevision() {
return this.paramsName === SOURCE_PARAM_NAME;
}, },
inputName() { inputName() {
return `${this.paramsName}_project_id`; return `${this.paramsName}_project_id`;
}, },
}, },
mounted() {
this.setDefaultRepo();
},
methods: { methods: {
onClick(repo) { onClick(project) {
this.selectedRepo = repo; this.emitTargetProject(project);
this.emitTargetProject(repo.name);
},
setDefaultRepo() {
this.selectedRepo = this.projectTo;
}, },
emitTargetProject(name) { emitTargetProject(project) {
if (!this.isSourceRevision) { this.$emit('selectProject', { direction: this.paramsName, project });
this.$emit('changeTargetProject', name);
}
}, },
}, },
}; };
...@@ -59,23 +53,23 @@ export default { ...@@ -59,23 +53,23 @@ export default {
<template> <template>
<div> <div>
<input type="hidden" :name="inputName" :value="selectedRepo.id" /> <input type="hidden" :name="inputName" :value="selectedProject.id" />
<gl-dropdown <gl-dropdown
:text="selectedRepo.name" :text="selectedProject.name"
:header-text="s__(`CompareRevisions|Select target project`)" :header-text="s__(`CompareRevisions|Select target project`)"
class="gl-w-full gl-font-monospace gl-sm-pr-3" class="gl-w-full gl-font-monospace gl-sm-pr-3"
toggle-class="gl-min-w-0" toggle-class="gl-min-w-0"
:disabled="isSourceRevision" :disabled="disableRepoDropdown"
> >
<template #header> <template #header>
<gl-search-box-by-type v-if="!isSourceRevision" v-model.trim="searchTerm" /> <gl-search-box-by-type v-if="!disableRepoDropdown" v-model.trim="searchTerm" />
</template> </template>
<template v-if="!isSourceRevision"> <template v-if="!disableRepoDropdown">
<gl-dropdown-item <gl-dropdown-item
v-for="repo in filteredRepos" v-for="repo in filteredRepos"
:key="repo.id" :key="repo.id"
is-check-item is-check-item
:is-checked="selectedRepo.id === repo.id" :is-checked="selectedProject.id === repo.id"
@click="onClick(repo)" @click="onClick(repo)"
> >
{{ repo.name }} {{ repo.name }}
......
...@@ -27,17 +27,14 @@ export default { ...@@ -27,17 +27,14 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
projects: {
type: Array,
required: false,
default: null,
}, },
data() { selectedProject: {
return { type: Object,
selectedRefsProjectPath: this.refsProjectPath, required: true,
};
},
methods: {
onChangeTargetProject(targetProjectName) {
if (this.paramsName === 'from') {
this.selectedRefsProjectPath = `/${targetProjectName}/refs`;
}
}, },
}, },
}; };
...@@ -52,13 +49,16 @@ export default { ...@@ -52,13 +49,16 @@ export default {
<repo-dropdown <repo-dropdown
class="gl-sm-w-half" class="gl-sm-w-half"
:params-name="paramsName" :params-name="paramsName"
@changeTargetProject="onChangeTargetProject" :projects="projects"
:selected-project="selectedProject"
v-on="$listeners"
/> />
<revision-dropdown <revision-dropdown
class="gl-sm-w-half gl-mt-3 gl-sm-mt-0" class="gl-sm-w-half gl-mt-3 gl-sm-mt-0"
:refs-project-path="selectedRefsProjectPath" :refs-project-path="refsProjectPath"
:params-name="paramsName" :params-name="paramsName"
:params-branch="paramsBranch" :params-branch="paramsBranch"
v-on="$listeners"
/> />
</div> </div>
</gl-card> </gl-card>
......
...@@ -56,6 +56,9 @@ export default { ...@@ -56,6 +56,9 @@ export default {
searchTerm: debounce(function debounceSearch() { searchTerm: debounce(function debounceSearch() {
this.searchBranchesAndTags(); this.searchBranchesAndTags();
}, SEARCH_DEBOUNCE_MS), }, SEARCH_DEBOUNCE_MS),
paramsBranch(newBranch) {
this.setSelectedRevision(newBranch);
},
}, },
mounted() { mounted() {
this.fetchBranchesAndTags(); this.fetchBranchesAndTags();
...@@ -84,7 +87,7 @@ export default { ...@@ -84,7 +87,7 @@ export default {
this.loading = true; this.loading = true;
if (reset) { if (reset) {
this.selectedRevision = this.getDefaultBranch(); this.setSelectedRevision(this.paramsBranch);
} }
return axios return axios
...@@ -108,10 +111,14 @@ export default { ...@@ -108,10 +111,14 @@ export default {
return this.paramsBranch || EMPTY_DROPDOWN_TEXT; return this.paramsBranch || EMPTY_DROPDOWN_TEXT;
}, },
onClick(revision) { onClick(revision) {
this.selectedRevision = revision; this.setSelectedRevision(revision);
this.$emit('selectRevision', { direction: this.paramsName, revision });
}, },
onSearchEnter() { onSearchEnter() {
this.selectedRevision = this.searchTerm; this.setSelectedRevision(this.searchTerm);
},
setSelectedRevision(revision) {
this.selectedRevision = revision || EMPTY_DROPDOWN_TEXT;
}, },
}, },
}; };
......
...@@ -22,10 +22,6 @@ export default function init() { ...@@ -22,10 +22,6 @@ export default function init() {
components: { components: {
CompareApp, CompareApp,
}, },
provide: {
projectTo: JSON.parse(projectTo),
projectsFrom: JSON.parse(projectsFrom),
},
render(createElement) { render(createElement) {
return createElement(CompareApp, { return createElement(CompareApp, {
props: { props: {
...@@ -35,6 +31,8 @@ export default function init() { ...@@ -35,6 +31,8 @@ export default function init() {
projectCompareIndexPath, projectCompareIndexPath,
projectMergeRequestPath, projectMergeRequestPath,
createMrPath, createMrPath,
defaultProject: JSON.parse(projectTo),
projects: JSON.parse(projectsFrom),
}, },
}); });
}, },
......
---
title: Add ability to swap revisions when comparing
merge_request: 60491
author:
type: added
...@@ -2,26 +2,19 @@ import { GlButton } from '@gitlab/ui'; ...@@ -2,26 +2,19 @@ import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import CompareApp from '~/projects/compare/components/app.vue'; import CompareApp from '~/projects/compare/components/app.vue';
import RevisionCard from '~/projects/compare/components/revision_card.vue'; import RevisionCard from '~/projects/compare/components/revision_card.vue';
import { appDefaultProps as defaultProps } from './mock_data';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
const projectCompareIndexPath = 'some/path';
const refsProjectPath = 'some/refs/path';
const paramsFrom = 'master';
const paramsTo = 'master';
describe('CompareApp component', () => { describe('CompareApp component', () => {
let wrapper; let wrapper;
const findSourceRevisionCard = () => wrapper.find('[data-testid="sourceRevisionCard"]');
const findTargetRevisionCard = () => wrapper.find('[data-testid="targetRevisionCard"]');
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(CompareApp, { wrapper = shallowMount(CompareApp, {
propsData: { propsData: {
projectCompareIndexPath, ...defaultProps,
refsProjectPath,
paramsFrom,
paramsTo,
projectMergeRequestPath: '',
createMrPath: '',
...props, ...props,
}, },
}); });
...@@ -39,16 +32,16 @@ describe('CompareApp component', () => { ...@@ -39,16 +32,16 @@ describe('CompareApp component', () => {
it('renders component with prop', () => { it('renders component with prop', () => {
expect(wrapper.props()).toEqual( expect(wrapper.props()).toEqual(
expect.objectContaining({ expect.objectContaining({
projectCompareIndexPath, projectCompareIndexPath: defaultProps.projectCompareIndexPath,
refsProjectPath, refsProjectPath: defaultProps.refsProjectPath,
paramsFrom, paramsFrom: defaultProps.paramsFrom,
paramsTo, paramsTo: defaultProps.paramsTo,
}), }),
); );
}); });
it('contains the correct form attributes', () => { it('contains the correct form attributes', () => {
expect(wrapper.attributes('action')).toBe(projectCompareIndexPath); expect(wrapper.attributes('action')).toBe(defaultProps.projectCompareIndexPath);
expect(wrapper.attributes('method')).toBe('POST'); expect(wrapper.attributes('method')).toBe('POST');
}); });
...@@ -87,6 +80,58 @@ describe('CompareApp component', () => { ...@@ -87,6 +80,58 @@ describe('CompareApp component', () => {
}); });
}); });
it('sets the selected project when the "selectProject" event is emitted', async () => {
const project = {
name: 'some-to-name',
id: '1',
};
findTargetRevisionCard().vm.$emit('selectProject', {
direction: 'to',
project,
});
await wrapper.vm.$nextTick();
expect(findTargetRevisionCard().props('selectedProject')).toEqual(
expect.objectContaining(project),
);
});
it('sets the selected revision when the "selectRevision" event is emitted', async () => {
const revision = 'some-revision';
findTargetRevisionCard().vm.$emit('selectRevision', {
direction: 'to',
revision,
});
await wrapper.vm.$nextTick();
expect(findSourceRevisionCard().props('paramsBranch')).toBe(revision);
});
describe('swap revisions button', () => {
const findSwapRevisionsButton = () => wrapper.find('[data-testid="swapRevisionsButton"]');
it('renders the swap revisions button', () => {
expect(findSwapRevisionsButton().exists()).toBe(true);
});
it('has the correct text', () => {
expect(findSwapRevisionsButton().text()).toBe('Swap revisions');
});
it('swaps revisions when clicked', async () => {
findSwapRevisionsButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findTargetRevisionCard().props('paramsBranch')).toBe(defaultProps.paramsTo);
expect(findSourceRevisionCard().props('paramsBranch')).toBe(defaultProps.paramsFrom);
});
});
describe('merge request buttons', () => { describe('merge request buttons', () => {
const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]');
const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]');
......
const refsProjectPath = 'some/refs/path';
const paramsName = 'to';
const paramsBranch = 'main';
const defaultProject = {
name: 'some-to-name',
id: '1',
};
export const appDefaultProps = {
projectCompareIndexPath: 'some/path',
projectMergeRequestPath: '',
projects: [defaultProject],
paramsFrom: 'main',
paramsTo: 'target/branch',
createMrPath: '',
refsProjectPath,
defaultProject,
};
export const revisionCardDefaultProps = {
selectedProject: defaultProject,
paramsBranch,
revisionText: 'Source',
refsProjectPath,
paramsName,
};
export const repoDropdownDefaultProps = {
selectedProject: defaultProject,
paramsName,
};
export const revisionDropdownDefaultProps = {
refsProjectPath,
paramsBranch,
paramsName,
};
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue';
import { revisionCardDefaultProps as defaultProps } from './mock_data';
const defaultProps = {
paramsName: 'to',
};
const projectToId = '1';
const projectToName = 'some-to-name';
const projectFromId = '2';
const projectFromName = 'some-from-name';
const defaultProvide = {
projectTo: { id: projectToId, name: projectToName },
projectsFrom: [
{ id: projectFromId, name: projectFromName },
{ id: 3, name: 'some-from-another-name' },
],
};
describe('RepoDropdown component', () => { describe('RepoDropdown component', () => {
let wrapper; let wrapper;
const createComponent = (props = {}, provide = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(RepoDropdown, { wrapper = shallowMount(RepoDropdown, {
propsData: { propsData: {
...defaultProps, ...defaultProps,
...props, ...props,
}, },
provide: {
...defaultProvide,
...provide,
},
}); });
}; };
...@@ -49,11 +29,11 @@ describe('RepoDropdown component', () => { ...@@ -49,11 +29,11 @@ describe('RepoDropdown component', () => {
}); });
it('set hidden input', () => { it('set hidden input', () => {
expect(findHiddenInput().attributes('value')).toBe(projectToId); expect(findHiddenInput().attributes('value')).toBe(defaultProps.selectedProject.id);
}); });
it('displays the project name in the disabled dropdown', () => { it('displays the project name in the disabled dropdown', () => {
expect(findGlDropdown().props('text')).toBe(projectToName); expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name);
expect(findGlDropdown().props('disabled')).toBe(true); expect(findGlDropdown().props('disabled')).toBe(true);
}); });
...@@ -66,31 +46,39 @@ describe('RepoDropdown component', () => { ...@@ -66,31 +46,39 @@ describe('RepoDropdown component', () => {
describe('Target Revision', () => { describe('Target Revision', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ paramsName: 'from' }); const projects = [
{
name: 'some-to-name',
id: '1',
},
];
createComponent({ paramsName: 'from', projects });
}); });
it('set hidden input of the selected project', () => { it('set hidden input of the selected project', () => {
expect(findHiddenInput().attributes('value')).toBe(projectToId); expect(findHiddenInput().attributes('value')).toBe(defaultProps.selectedProject.id);
}); });
it('displays matching project name of the source revision initially in the dropdown', () => { it('displays matching project name of the source revision initially in the dropdown', () => {
expect(findGlDropdown().props('text')).toBe(projectToName); expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name);
}); });
it('updates the hiddin input value when onClick method is triggered', async () => { it('updates the hidden input value when onClick method is triggered', async () => {
const repoId = '100'; const repoId = '1';
wrapper.vm.onClick({ id: repoId }); wrapper.vm.onClick({ id: repoId });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(findHiddenInput().attributes('value')).toBe(repoId); expect(findHiddenInput().attributes('value')).toBe(repoId);
}); });
it('emits `changeTargetProject` event when another target project is selected', async () => { it('emits `selectProject` event when another target project is selected', async () => {
const index = 1; findGlDropdown().findAll(GlDropdownItem).at(0).vm.$emit('click');
const { projectsFrom } = defaultProvide;
findGlDropdown().findAll(GlDropdownItem).at(index).vm.$emit('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.emitted('changeTargetProject')[0][0]).toEqual(projectsFrom[index].name); expect(wrapper.emitted('selectProject')[0][0]).toEqual({
direction: 'from',
project: { id: '1', name: 'some-to-name' },
});
}); });
}); });
}); });
...@@ -3,13 +3,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -3,13 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue';
import RevisionCard from '~/projects/compare/components/revision_card.vue'; import RevisionCard from '~/projects/compare/components/revision_card.vue';
import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue';
import { revisionCardDefaultProps as defaultProps } from './mock_data';
const defaultProps = {
refsProjectPath: 'some/refs/path',
revisionText: 'Source',
paramsName: 'to',
paramsBranch: 'master',
};
describe('RepoDropdown component', () => { describe('RepoDropdown component', () => {
let wrapper; let wrapper;
......
import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue';
import { revisionDropdownDefaultProps as defaultProps } from './mock_data';
const defaultProps = {
refsProjectPath: 'some/refs/path',
paramsName: 'from',
paramsBranch: 'master',
};
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -142,4 +137,17 @@ describe('RevisionDropdown component', () => { ...@@ -142,4 +137,17 @@ describe('RevisionDropdown component', () => {
expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch);
}); });
}); });
it('emits `selectRevision` event when another revision is selected', async () => {
createComponent();
wrapper.vm.branches = ['some-branch'];
await wrapper.vm.$nextTick();
findGlDropdown().findAll(GlDropdownItem).at(0).vm.$emit('click');
expect(wrapper.emitted('selectRevision')[0][0]).toEqual({
direction: 'to',
revision: 'some-branch',
});
});
}); });
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