diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index bee93e434d68cd55f613cc9b39dad42ccf8008a1..d2fb524489e61d202d73009815cce6f33d29ae4d 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -1,12 +1,12 @@ + + diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..822dfc09d813366c14f1299b861e21ac2b002862 --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -0,0 +1,93 @@ + + + diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue new file mode 100644 index 0000000000000000000000000000000000000000..15d247923102cd0e8fe19a13304a241718c7c5b5 --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue index 13d80b5ae0b6ceca7fd520db7b7b4bdadb0a7a87..d8947d60a8e7db45189b09d8e49ba473ac2920c6 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue @@ -4,6 +4,8 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; +const emptyDropdownText = s__('CompareRevisions|Select branch/tag'); + export default { components: { GlDropdown, @@ -16,10 +18,6 @@ export default { type: String, required: true, }, - revisionText: { - type: String, - required: true, - }, paramsName: { type: String, required: true, @@ -55,12 +53,24 @@ export default { return this.filteredTags.length; }, }, + watch: { + refsProjectPath(newRefsProjectPath, oldRefsProjectPath) { + if (newRefsProjectPath !== oldRefsProjectPath) { + this.fetchBranchesAndTags(true); + } + }, + }, mounted() { this.fetchBranchesAndTags(); }, methods: { - fetchBranchesAndTags() { + fetchBranchesAndTags(reset = false) { const endpoint = this.refsProjectPath; + this.loading = true; + + if (reset) { + this.selectedRevision = emptyDropdownText; + } return axios .get(endpoint) @@ -70,9 +80,9 @@ export default { }) .catch(() => { createFlash({ - message: `${s__( - 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.', - )}`, + message: s__( + 'CompareRevisions|There was an error while loading the branch/tag list. Please try again.', + ), }); }) .finally(() => { @@ -80,7 +90,7 @@ export default { }); }, getDefaultBranch() { - return this.paramsBranch || s__('CompareRevisions|Select branch/tag'); + return this.paramsBranch || emptyDropdownText; }, onClick(revision) { this.selectedRevision = revision; @@ -93,53 +103,46 @@ export default { diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue new file mode 100644 index 0000000000000000000000000000000000000000..a5e2c986b1223c93ddf56e499c3ae87c6e663cb3 --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue @@ -0,0 +1,145 @@ + + + diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index 4337eecb667be6a24cf631f8c3050e98d9e4e812..4ba4e308cd4ed6b3ed762d7bf87be847657fed3f 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -1,8 +1,46 @@ import Vue from 'vue'; import CompareApp from './components/app.vue'; +import CompareAppLegacy from './components/app_legacy.vue'; export default function init() { const el = document.getElementById('js-compare-selector'); + + if (gon.features?.compareRepoDropdown) { + const { + refsProjectPath, + paramsFrom, + paramsTo, + projectCompareIndexPath, + projectMergeRequestPath, + createMrPath, + projectTo, + projectsFrom, + } = el.dataset; + + return new Vue({ + el, + components: { + CompareApp, + }, + provide: { + projectTo: JSON.parse(projectTo), + projectsFrom: JSON.parse(projectsFrom), + }, + render(createElement) { + return createElement(CompareApp, { + props: { + refsProjectPath, + paramsFrom, + paramsTo, + projectCompareIndexPath, + projectMergeRequestPath, + createMrPath, + }, + }); + }, + }); + } + const { refsProjectPath, paramsFrom, @@ -15,10 +53,10 @@ export default function init() { return new Vue({ el, components: { - CompareApp, + CompareAppLegacy, }, render(createElement) { - return createElement(CompareApp, { + return createElement(CompareAppLegacy, { props: { refsProjectPath, paramsFrom, diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 8251cdb9bbba6bcdd8bab8b202c29ae59a7af42a..be857727cf7da46407c69f5647c181057de329ee 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -1008,6 +1008,18 @@ pre.light-well { } } +.compare-revision-cards { + @media (min-width: $breakpoint-lg) { + .gl-card { + width: calc(50% - 15px); + } + + .compare-ellipsis { + width: 30px; + } + } +} + .clearable-input { position: relative; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index d424dcbf8f2d246e416ca365674c732c725f303e..d49b58df69bb98121fc903804c809768be885275 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -157,3 +157,17 @@ margin-bottom: $gl-spacing-scale-4 !important; } } + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168 +.gl-sm-pr-3 { + @media (min-width: $breakpoint-sm) { + padding-right: $gl-spacing-scale-3; + } +} + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168 +.gl-sm-w-half { + @media (min-width: $breakpoint-sm) { + width: 50%; + } +} diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 133055cc3172572f84251b949af0407f194f5545..eecd338b7cc934509e3d9addc03bb9ccb8bc96d0 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -20,6 +20,10 @@ class Projects::CompareController < Projects::ApplicationController # Validation before_action :validate_refs! + before_action do + push_frontend_feature_flag(:compare_repo_dropdown) + end + feature_category :source_code_management def index diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 3f9aa24a569eb2059b38bb1ed80b68f32371e1a5..39d4a3b2eb25d202017e22bc67aa9156d51eb89f 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -13,8 +13,17 @@ = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: ''.html_safe, b_close: ''.html_safe } .prepend-top-20 - #js-compare-selector{ data: { project_compare_index_path: project_compare_index_path(@project), - refs_project_path: refs_project_path(@project), - params_from: params[:from], params_to: params[:to], - project_merge_request_path: @merge_request.present? ? project_merge_request_path(@project, @merge_request) : '', - create_mr_path: create_mr_button? ? create_mr_path : '' } } + - if Feature.enabled?(:compare_repo_dropdown) + #js-compare-selector{ data: { project_compare_index_path: project_compare_index_path(@project), + refs_project_path: refs_project_path(@project), + params_from: params[:from], params_to: params[:to], + project_merge_request_path: @merge_request.present? ? project_merge_request_path(@project, @merge_request) : '', + create_mr_path: create_mr_button? ? create_mr_path : '', + project_to: { id: @project.id, name: @project.full_path }.to_json, + projects_from: target_projects(@project).map { |project| { id:project.id, name: project.full_path } }.to_json } } + - else + #js-compare-selector{ data: { project_compare_index_path: project_compare_index_path(@project), + refs_project_path: refs_project_path(@project), + params_from: params[:from], params_to: params[:to], + project_merge_request_path: @merge_request.present? ? project_merge_request_path(@project, @merge_request) : '', + create_mr_path: create_mr_button? ? create_mr_path : '' } } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c096e1bcf9080dd4cc0463888d28bc48eb040d8c..1e5dce3b09ec96d26886fa408cce401bc54b94a1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7569,12 +7569,21 @@ msgstr "" msgid "CompareRevisions|Filter by Git revision" msgstr "" +msgid "CompareRevisions|Select Git revision" +msgstr "" + msgid "CompareRevisions|Select branch/tag" msgstr "" +msgid "CompareRevisions|Select target project" +msgstr "" + msgid "CompareRevisions|Tags" msgstr "" +msgid "CompareRevisions|There was an error while loading the branch/tag list. Please try again." +msgstr "" + msgid "CompareRevisions|There was an error while updating the branch/tag list. Please try again." msgstr "" diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4c7f0d5ccccd24a6410446a85ac59c9dba349478 --- /dev/null +++ b/spec/frontend/projects/compare/components/app_legacy_spec.js @@ -0,0 +1,116 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CompareApp from '~/projects/compare/components/app_legacy.vue'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; + +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', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(CompareApp, { + propsData: { + projectCompareIndexPath, + refsProjectPath, + paramsFrom, + paramsTo, + projectMergeRequestPath: '', + createMrPath: '', + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('renders component with prop', () => { + expect(wrapper.props()).toEqual( + expect.objectContaining({ + projectCompareIndexPath, + refsProjectPath, + paramsFrom, + paramsTo, + }), + ); + }); + + it('contains the correct form attributes', () => { + expect(wrapper.attributes('action')).toBe(projectCompareIndexPath); + expect(wrapper.attributes('method')).toBe('POST'); + }); + + it('has input with csrf token', () => { + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('has ellipsis', () => { + expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true); + }); + + it('render Source and Target BranchDropdown components', () => { + const branchDropdowns = wrapper.findAll(RevisionDropdown); + + expect(branchDropdowns.length).toBe(2); + expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); + expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + }); + + describe('compare button', () => { + const findCompareButton = () => wrapper.find(GlButton); + + it('renders button', () => { + expect(findCompareButton().exists()).toBe(true); + }); + + it('submits form', () => { + findCompareButton().vm.$emit('click'); + expect(wrapper.find('form').element.submit).toHaveBeenCalled(); + }); + + it('has compare text', () => { + expect(findCompareButton().text()).toBe('Compare'); + }); + }); + + describe('merge request buttons', () => { + const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); + const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); + + it('does not have merge request buttons', () => { + createComponent(); + expect(findProjectMrButton().exists()).toBe(false); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('has "View open merge request" button', () => { + createComponent({ + projectMergeRequestPath: 'some/project/merge/request/path', + }); + expect(findProjectMrButton().exists()).toBe(true); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('has "Create merge request" button', () => { + createComponent({ + createMrPath: 'some/create/create/mr/path', + }); + expect(findProjectMrButton().exists()).toBe(false); + expect(findCreateMrButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js index d28a30e93b11337b788f47fb1c4218459e11ffa7..6de06e4373cb223f43ba9d70280b9ad2df2edb87 100644 --- a/spec/frontend/projects/compare/components/app_spec.js +++ b/spec/frontend/projects/compare/components/app_spec.js @@ -1,7 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CompareApp from '~/projects/compare/components/app.vue'; -import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; +import RevisionCard from '~/projects/compare/components/revision_card.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -63,11 +63,11 @@ describe('CompareApp component', () => { }); it('render Source and Target BranchDropdown components', () => { - const branchDropdowns = wrapper.findAll(RevisionDropdown); + const revisionCards = wrapper.findAll(RevisionCard); - expect(branchDropdowns.length).toBe(2); - expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); - expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + expect(revisionCards.length).toBe(2); + expect(revisionCards.at(0).props('revisionText')).toBe('Source'); + expect(revisionCards.at(1).props('revisionText')).toBe('Target'); }); describe('compare button', () => { diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..af76632515cf2aa96958f44d354359e3eb7ab3d2 --- /dev/null +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -0,0 +1,98 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; + +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', () => { + let wrapper; + + const createComponent = (props = {}, provide = {}) => { + wrapper = shallowMount(RepoDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + + describe('Source Revision', () => { + beforeEach(() => { + createComponent(); + }); + + it('set hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(projectToId); + }); + + it('displays the project name in the disabled dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectToName); + expect(findGlDropdown().props('disabled')).toBe(true); + }); + + it('does not emit `changeTargetProject` event', async () => { + wrapper.vm.emitTargetProject('foo'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('changeTargetProject')).toBeUndefined(); + }); + }); + + describe('Target Revision', () => { + beforeEach(() => { + createComponent({ paramsName: 'from' }); + }); + + it('set hidden input of the first project', () => { + expect(findHiddenInput().attributes('value')).toBe(projectFromId); + }); + + it('displays the first project name initially in the dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectFromName); + }); + + it('updates the hiddin input value when onClick method is triggered', async () => { + const repoId = '100'; + wrapper.vm.onClick({ id: repoId }); + await wrapper.vm.$nextTick(); + expect(findHiddenInput().attributes('value')).toBe(repoId); + }); + + it('emits initial `changeTargetProject` event with target project', () => { + expect(wrapper.emitted('changeTargetProject')).toEqual([[projectFromName]]); + }); + + it('emits `changeTargetProject` event when another target project is selected', async () => { + const newTargetProject = 'new-from-name'; + wrapper.vm.$emit('changeTargetProject', newTargetProject); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('changeTargetProject')[1]).toEqual([newTargetProject]); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..83f858f445493f543a6d720bd2a25755431a66cd --- /dev/null +++ b/spec/frontend/projects/compare/components/revision_card_spec.js @@ -0,0 +1,49 @@ +import { GlCard } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; +import RevisionCard from '~/projects/compare/components/revision_card.vue'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; + +const defaultProps = { + refsProjectPath: 'some/refs/path', + revisionText: 'Source', + paramsName: 'to', + paramsBranch: 'master', +}; + +describe('RepoDropdown component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(RevisionCard, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlCard, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('displays revision text', () => { + expect(wrapper.find(GlCard).text()).toContain(defaultProps.revisionText); + }); + + it('renders RepoDropdown component', () => { + expect(wrapper.findAll(RepoDropdown).exists()).toBe(true); + }); + + it('renders RevisionDropdown component', () => { + expect(wrapper.findAll(RevisionDropdown).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8ed419a4a61709f6c2717a60923b80bfc50266d1 --- /dev/null +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -0,0 +1,92 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; + +const defaultProps = { + refsProjectPath: 'some/refs/path', + revisionText: 'Target', + paramsName: 'from', + paramsBranch: 'master', +}; + +jest.mock('~/flash'); + +describe('RevisionDropdown component', () => { + let wrapper; + let axiosMock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(RevisionDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + + it('sets hidden input', () => { + createComponent(); + expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe( + defaultProps.paramsBranch, + ); + }); + + it('update the branches on success', async () => { + const Branches = ['branch-1', 'branch-2']; + const Tags = ['tag-1', 'tag-2', 'tag-3']; + + axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, { + Branches, + Tags, + }); + + createComponent(); + + await axios.waitForAll(); + + expect(wrapper.vm.branches).toEqual(Branches); + expect(wrapper.vm.tags).toEqual(Tags); + }); + + it('shows flash message on error', async () => { + axiosMock.onGet('some/invalid/path').replyOnce(404); + + createComponent(); + + await wrapper.vm.fetchBranchesAndTags(); + expect(createFlash).toHaveBeenCalled(); + }); + + describe('GlDropdown component', () => { + it('renders props', () => { + createComponent(); + expect(wrapper.props()).toEqual(expect.objectContaining(defaultProps)); + }); + + it('display default text', () => { + createComponent({ + paramsBranch: null, + }); + expect(findGlDropdown().props('text')).toBe('Select branch/tag'); + }); + + it('display params branch text', () => { + createComponent(); + expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index f3ff5e26d2b8acc1dffa633b3aeefe0d26dacf4d..69d3167c99c53ab2c1aafc972faf7965a1b938a2 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -7,7 +7,6 @@ import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vu const defaultProps = { refsProjectPath: 'some/refs/path', - revisionText: 'Target', paramsName: 'from', paramsBranch: 'master', }; @@ -57,7 +56,6 @@ describe('RevisionDropdown component', () => { createComponent(); await axios.waitForAll(); - expect(wrapper.vm.branches).toEqual(Branches); expect(wrapper.vm.tags).toEqual(Tags); }); @@ -71,6 +69,22 @@ describe('RevisionDropdown component', () => { expect(createFlash).toHaveBeenCalled(); }); + it('makes a new request when refsProjectPath is changed', async () => { + jest.spyOn(axios, 'get'); + + const newRefsProjectPath = 'new-selected-project-path'; + + createComponent(); + + wrapper.setProps({ + ...defaultProps, + refsProjectPath: newRefsProjectPath, + }); + + await axios.waitForAll(); + expect(axios.get).toHaveBeenLastCalledWith(newRefsProjectPath); + }); + describe('GlDropdown component', () => { it('renders props', () => { createComponent();