diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 3615ffb3013d55608bbf98dce4ca95a115f8e62b..eb2a88b2da860310d8cdd1bdbde76b89e8b5e1db 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -21,6 +21,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility';
import syntaxHighlight from './syntax_highlight';
import Notes from './notes';
import { polyfillSticky } from './lib/utils/sticky';
+import initAddContextCommitsTriggers from './add_context_commits_modal';
import { __ } from './locale';
// MergeRequestTabs
@@ -340,6 +341,7 @@ export default class MergeRequestTabs {
this.scrollToElement('#commits');
this.toggleLoading(false);
+ initAddContextCommitsTriggers();
})
.catch(() => {
this.toggleLoading(false);
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 9a69afc6044e4d9258212114b6df606c9068869a..e6378fd9168c4c7bc9dc6ceb09222bc3ac4445e5 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -388,3 +388,9 @@
display: block;
color: $link-color;
}
+
+.add-review-item {
+ .gl-tab-nav-item {
+ height: 100%;
+ }
+}
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index d1b8d298f67375acc42fad4c505715fee2968438..90d65a8c968a9eb995fb161740d555c099726523 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -80,7 +80,7 @@ def show
@note = @project.notes.new(noteable: @merge_request)
@noteable = @merge_request
- @commits_count = @merge_request.commits_count
+ @commits_count = @merge_request.commits_count + @merge_request.context_commits_count
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
@current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
@@ -114,6 +114,12 @@ def show
end
def commits
+ # Get context commits from repository
+ @context_commits =
+ set_commits_for_rendering(
+ @merge_request.recent_context_commits
+ )
+
# Get commits from repository
# or from cache if already merged
@commits =
diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb
index f1b3eb43e84c277b5a3c0621107b49c7490d8107..de89a556ee01049925f81fc58dfd40f0cef8b8a4 100644
--- a/app/finders/context_commits_finder.rb
+++ b/app/finders/context_commits_finder.rb
@@ -25,7 +25,7 @@ def init_collection
if search.present?
search_commits
else
- project.repository.commits(merge_request.source_branch, { limit: limit, offset: offset })
+ project.repository.commits(merge_request.target_branch, { limit: limit, offset: offset })
end
commits
@@ -47,7 +47,7 @@ def search_commits
commits = [commit_by_sha] if commit_by_sha
end
else
- commits = project.repository.find_commits_by_message(search, nil, nil, 20)
+ commits = project.repository.find_commits_by_message(search, merge_request.target_branch, nil, 20)
end
commits
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index a3eb684698a6720433ad95b7c6a7d13f17db2499..6c940febf2c9c2eb1d17f27638cac7c1a0587489 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -40,7 +40,7 @@ class MergeRequest < ApplicationRecord
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
has_many :merge_request_diffs
- has_many :merge_request_context_commits
+ has_many :merge_request_context_commits, inverse_of: :merge_request
has_many :merge_request_context_commit_diff_files, through: :merge_request_context_commits, source: :diff_files
has_one :merge_request_diff,
@@ -427,7 +427,7 @@ def to_reference(from = nil, full: false)
end
def context_commits(limit: nil)
- @context_commits ||= merge_request_context_commits.limit(limit).map(&:to_commit)
+ @context_commits ||= merge_request_context_commits.order_by_committed_date_desc.limit(limit).map(&:to_commit)
end
def recent_context_commits
diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb
index de97fc33f8d448c0bf87263741abc13ff1066f18..a2982a5dd7310857cbc2e2fe0ff0be84504e099a 100644
--- a/app/models/merge_request_context_commit.rb
+++ b/app/models/merge_request_context_commit.rb
@@ -12,6 +12,9 @@ class MergeRequestContextCommit < ApplicationRecord
validates :sha, presence: true
validates :sha, uniqueness: { message: 'has already been added' }
+ # Sort by committed date in descending order to ensure latest commits comes on the top
+ scope :order_by_committed_date_desc, -> { order('committed_date DESC') }
+
# delete all MergeRequestContextCommit & MergeRequestContextCommitDiffFile for given merge_request & commit SHAs
def self.delete_bulk(merge_request, commits)
commit_ids = commits.map(&:sha)
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index e413bd787892c8397ccd3d387ecc0b4a63306fe1..293500a6c31647b3ec06b70944974423b87d3373 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -1,8 +1,10 @@
- merge_request = local_assigns.fetch(:merge_request, nil)
- project = local_assigns.fetch(:project) { merge_request&.project }
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
+- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- commits = @commits
+- context_commits = @context_commits
- hidden = @hidden_commit_count
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits|
@@ -14,11 +16,26 @@
%ul.content-list.commit-list.flex-list
= render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }
+- if context_commits.present?
+ %li.commit-header.js-commit-header
+ %span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count
+ - if project.context_commits_enabled? && can_update_merge_request
+ %button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } }
+ = _('Add/remove')
+
+ %li.commits-row
+ %ul.content-list.commit-list.flex-list
+ = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }
+
- if hidden > 0
%li.alert.alert-warning
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
-- if commits.size == 0
+- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty?
+ %button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } }
+ = _('Add previously merged commits')
+
+- if commits.size == 0 && context_commits.nil?
.mt-4.text-center
.bold
= _('Your search didn\'t match any commits.')
diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml
index b414518b5979bbea9e3257e0061ed4fe9464e4bb..178e57b08b336a5744d45d0d0d9c2ad1e14b590a 100644
--- a/app/views/projects/merge_requests/_commits.html.haml
+++ b/app/views/projects/merge_requests/_commits.html.haml
@@ -1,8 +1,18 @@
-- if @commits.empty?
- .commits-empty
- %h4
- There are no commits yet.
+- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
+
+- if @commits.empty? && @context_commits.empty?
+ .commits-empty.mt-5
= custom_icon ('illustration_no_commits')
+ %h4
+ = _('There are no commits yet.')
+ - if @project&.context_commits_enabled? && can_update_merge_request
+ %p
+ = _('Push commits to the source branch or add previously merged commits to review them.')
+ %button.btn.btn-primary.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } }
+ = _('Add previously merged commits')
- else
%ol#commits-list.list-unstyled
= render "projects/commits/commits", merge_request: @merge_request
+
+- if @project&.context_commits_enabled? && can_update_merge_request && @merge_request.iid
+ .add-review-item-modal-wrapper{ data: { context_commits_path: context_commits_project_json_merge_request_url(@merge_request&.project, @merge_request, :json), target_branch: @merge_request.target_branch, merge_request_iid: @merge_request.iid, project_id: @merge_request.project.id } }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 026a18c86a77345b66f08375033a8231b69a7558..81c18223ffdba6f393ce0ec140d5c45214e3a7c6 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -239,6 +239,11 @@ msgid_plural "%d personal projects will be removed and cannot be restored."
msgstr[0] ""
msgstr[1] ""
+msgid "%d previously merged commit"
+msgid_plural "%d previously merged commits"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d project"
msgid_plural "%d projects"
msgstr[0] ""
@@ -1519,9 +1524,15 @@ msgstr ""
msgid "Add new directory"
msgstr ""
+msgid "Add or remove previously merged commits"
+msgstr ""
+
msgid "Add or subtract spent time"
msgstr ""
+msgid "Add previously merged commits"
+msgstr ""
+
msgid "Add reaction"
msgstr ""
@@ -1567,6 +1578,15 @@ msgstr ""
msgid "Add webhook"
msgstr ""
+msgid "Add/remove"
+msgstr ""
+
+msgid "AddContextCommits|Add previously merged commits"
+msgstr ""
+
+msgid "AddContextCommits|Add/remove"
+msgstr ""
+
msgid "AddMember|No users specified."
msgstr ""
@@ -6113,6 +6133,9 @@ msgstr ""
msgid "Commits to"
msgstr ""
+msgid "Commits you select appear here. Go to the first tab and select commits to add to this merge request."
+msgstr ""
+
msgid "Commits|An error occurred while fetching merge requests data."
msgstr ""
@@ -6614,6 +6637,15 @@ msgstr ""
msgid "Contents of .gitlab-ci.yml"
msgstr ""
+msgid "ContextCommits|Failed to create context commits. Please try again."
+msgstr ""
+
+msgid "ContextCommits|Failed to create/remove context commits. Please try again."
+msgstr ""
+
+msgid "ContextCommits|Failed to delete context commits. Please try again."
+msgstr ""
+
msgid "Continue"
msgstr ""
@@ -15893,6 +15925,9 @@ msgstr ""
msgid "No child epics match applied filters"
msgstr ""
+msgid "No commits present here"
+msgstr ""
+
msgid "No connection could be made to a Gitaly Server, please check your logs!"
msgstr ""
@@ -19360,6 +19395,9 @@ msgstr ""
msgid "Push an existing folder"
msgstr ""
+msgid "Push commits to the source branch or add previously merged commits to review them."
+msgstr ""
+
msgid "Push events"
msgstr ""
@@ -20713,6 +20751,9 @@ msgstr ""
msgid "Search by author"
msgstr ""
+msgid "Search by commit title or SHA"
+msgstr ""
+
msgid "Search by message"
msgstr ""
@@ -21366,6 +21407,9 @@ msgstr ""
msgid "Select user"
msgstr ""
+msgid "Selected commits"
+msgstr ""
+
msgid "Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users."
msgstr ""
@@ -23924,6 +23968,9 @@ msgstr ""
msgid "There are no closed merge requests"
msgstr ""
+msgid "There are no commits yet."
+msgstr ""
+
msgid "There are no custom project templates set up for this GitLab instance. They are enabled from GitLab's Admin Area. Contact your GitLab instance administrator to setup custom project templates."
msgstr ""
@@ -25446,6 +25493,9 @@ msgstr ""
msgid "Unable to generate new instance ID"
msgstr ""
+msgid "Unable to load commits. Try again later."
+msgstr ""
+
msgid "Unable to load file contents. Try again later."
msgstr ""
@@ -27771,6 +27821,9 @@ msgstr ""
msgid "Your search didn't match any commits."
msgstr ""
+msgid "Your search didn't match any commits. Try a different query."
+msgstr ""
+
msgid "Your subscription expired!"
msgstr ""
diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
new file mode 100644
index 0000000000000000000000000000000000000000..5fad0d07f97008c9a2e47f44ff668face63e1b59
--- /dev/null
+++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
@@ -0,0 +1,50 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..6904e34db5dc899fcb740c7f7cad12e610ac996c
--- /dev/null
+++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js
@@ -0,0 +1,174 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlModal, GlSearchBoxByType } from '@gitlab/ui';
+import AddReviewItemsModal from '~/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue';
+import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit';
+
+import defaultState from '~/add_context_commits_modal/store/state';
+import mutations from '~/add_context_commits_modal/store/mutations';
+import * as actions from '~/add_context_commits_modal/store/actions';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('AddContextCommitsModal', () => {
+ let wrapper;
+ let store;
+ const createContextCommits = jest.fn();
+ const removeContextCommits = jest.fn();
+ const resetModalState = jest.fn();
+ const searchCommits = jest.fn();
+ const { commit } = getDiffWithCommit();
+
+ const createWrapper = (props = {}) => {
+ store = new Vuex.Store({
+ mutations,
+ state: {
+ ...defaultState(),
+ },
+ actions: {
+ ...actions,
+ searchCommits,
+ createContextCommits,
+ removeContextCommits,
+ resetModalState,
+ },
+ });
+
+ wrapper = shallowMount(AddReviewItemsModal, {
+ localVue,
+ store,
+ propsData: {
+ contextCommitsPath: '',
+ targetBranch: 'master',
+ mergeRequestIid: 1,
+ projectId: 1,
+ ...props,
+ },
+ });
+ return wrapper;
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findSearch = () => wrapper.find(GlSearchBoxByType);
+
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders modal with 2 tabs', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('an ok button labeled "Save changes"', () => {
+ expect(findModal().attributes('ok-title')).toEqual('Save changes');
+ });
+
+ describe('when in first tab, renders a modal with', () => {
+ it('renders the search box component', () => {
+ expect(findSearch().exists()).toBe(true);
+ });
+
+ it('when user starts entering text in search box, it calls action "searchCommits" after waiting for 500s', () => {
+ const searchText = 'abcd';
+ findSearch().vm.$emit('input', searchText);
+ expect(searchCommits).not.toBeCalled();
+ jest.advanceTimersByTime(500);
+ expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText, undefined);
+ });
+
+ it('disabled ok button when no row is selected', () => {
+ expect(findModal().attributes('ok-disabled')).toBe('true');
+ });
+
+ it('enabled ok button when atleast one row is selected', () => {
+ wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }];
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findModal().attributes('ok-disabled')).toBeFalsy();
+ });
+ });
+ });
+
+ describe('when in second tab, renders a modal with', () => {
+ beforeEach(() => {
+ wrapper.vm.$store.state.tabIndex = 1;
+ });
+ it('a disabled ok button when no row is selected', () => {
+ expect(findModal().attributes('ok-disabled')).toBe('true');
+ });
+
+ it('an enabled ok button when atleast one row is selected', () => {
+ wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }];
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findModal().attributes('ok-disabled')).toBeFalsy();
+ });
+ });
+
+ it('a disabled ok button in first tab, when row is selected in second tab', () => {
+ createWrapper({ selectedContextCommits: [commit] });
+ expect(wrapper.find(GlModal).attributes('ok-disabled')).toBe('true');
+ });
+ });
+
+ describe('has an ok button when clicked calls action', () => {
+ it('"createContextCommits" when only new commits to be added ', () => {
+ wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }];
+ findModal().vm.$emit('ok');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createContextCommits).toHaveBeenCalledWith(
+ expect.anything(),
+ { commits: [{ ...commit, isSelected: true }], forceReload: true },
+ undefined,
+ );
+ });
+ });
+ it('"removeContextCommits" when only added commits are to be removed ', () => {
+ wrapper.vm.$store.state.toRemoveCommits = [commit.short_id];
+ findModal().vm.$emit('ok');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), true, undefined);
+ });
+ });
+ it('"createContextCommits" and "removeContextCommits" when new commits are to be added and old commits are to be removed', () => {
+ wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }];
+ wrapper.vm.$store.state.toRemoveCommits = [commit.short_id];
+ findModal().vm.$emit('ok');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createContextCommits).toHaveBeenCalledWith(
+ expect.anything(),
+ { commits: [{ ...commit, isSelected: true }] },
+ undefined,
+ );
+ expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), undefined, undefined);
+ });
+ });
+ });
+
+ describe('has a cancel button when clicked', () => {
+ it('does not call "createContextCommits" or "removeContextCommits"', () => {
+ findModal().vm.$emit('cancel');
+ expect(createContextCommits).not.toHaveBeenCalled();
+ expect(removeContextCommits).not.toHaveBeenCalled();
+ });
+ it('"resetModalState" to reset all the modal state', () => {
+ findModal().vm.$emit('cancel');
+ expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined);
+ });
+ });
+
+ describe('when model is closed by clicking the "X" button or by pressing "ESC" key', () => {
+ it('does not call "createContextCommits" or "removeContextCommits"', () => {
+ findModal().vm.$emit('close');
+ expect(createContextCommits).not.toHaveBeenCalled();
+ expect(removeContextCommits).not.toHaveBeenCalled();
+ });
+ it('"resetModalState" to reset all the modal state', () => {
+ findModal().vm.$emit('close');
+ expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined);
+ });
+ });
+});
diff --git a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4e65713a680f385a328ac6337bb926695de15223
--- /dev/null
+++ b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
+import CommitItem from '~/diffs/components/commit_item.vue';
+import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit';
+
+describe('ReviewTabContainer', () => {
+ let wrapper;
+ const { commit } = getDiffWithCommit();
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMount(ReviewTabContainer, {
+ propsData: {
+ tab: 'commits',
+ isLoading: false,
+ loadingError: false,
+ loadingFailedText: 'Failed to load commits',
+ commits: [],
+ selectedRow: [],
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows loading icon when commits are being loaded', () => {
+ createWrapper({ isLoading: true });
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('shows loading error text when API call fails', () => {
+ createWrapper({ loadingError: true });
+ expect(wrapper.text()).toContain('Failed to load commits');
+ });
+
+ it('shows "No commits present here" when commits are not present', () => {
+ expect(wrapper.text()).toContain('No commits present here');
+ });
+
+ it('renders all passed commits as list', () => {
+ createWrapper({ commits: [commit] });
+ expect(wrapper.findAll(CommitItem).length).toBe(1);
+ });
+});
diff --git a/spec/frontend/add_context_commits_modal/store/actions_spec.js b/spec/frontend/add_context_commits_modal/store/actions_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..df9422b64bcdef2f13edc1e59b096ca3c450a70d
--- /dev/null
+++ b/spec/frontend/add_context_commits_modal/store/actions_spec.js
@@ -0,0 +1,239 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setBaseConfig,
+ setTabIndex,
+ setCommits,
+ createContextCommits,
+ fetchContextCommits,
+ setContextCommits,
+ removeContextCommits,
+ setSelectedCommits,
+ setSearchText,
+ setToRemoveCommits,
+ resetModalState,
+} from '~/add_context_commits_modal/store/actions';
+import * as types from '~/add_context_commits_modal/store/mutation_types';
+import { TEST_HOST } from 'helpers/test_constants';
+import testAction from '../../helpers/vuex_action_helper';
+
+describe('AddContextCommitsModalStoreActions', () => {
+ const contextCommitEndpoint =
+ '/api/v4/projects/gitlab-org%2fgitlab/merge_requests/1/context_commits';
+ const mergeRequestIid = 1;
+ const projectId = 1;
+ const projectPath = 'gitlab-org/gitlab';
+ const contextCommitsPath = `${TEST_HOST}/gitlab-org/gitlab/-/merge_requests/1/context_commits.json`;
+ const dummyCommit = {
+ id: 1,
+ title: 'dummy commit',
+ short_id: 'abcdef',
+ committed_date: '2020-06-12',
+ };
+ gon.api_version = 'v4';
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setBaseConfig', () => {
+ it('commits SET_BASE_CONFIG', done => {
+ const options = { contextCommitsPath, mergeRequestIid, projectId };
+ testAction(
+ setBaseConfig,
+ options,
+ {
+ contextCommitsPath: '',
+ mergeRequestIid,
+ projectId,
+ },
+ [
+ {
+ type: types.SET_BASE_CONFIG,
+ payload: options,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setTabIndex', () => {
+ it('commits SET_TABINDEX', done => {
+ testAction(
+ setTabIndex,
+ { tabIndex: 1 },
+ { tabIndex: 0 },
+ [{ type: types.SET_TABINDEX, payload: { tabIndex: 1 } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setCommits', () => {
+ it('commits SET_COMMITS', done => {
+ testAction(
+ setCommits,
+ { commits: [], silentAddition: false },
+ { isLoadingCommits: false, commits: [] },
+ [{ type: types.SET_COMMITS, payload: [] }],
+ [],
+ done,
+ );
+ });
+
+ it('commits SET_COMMITS_SILENT', done => {
+ testAction(
+ setCommits,
+ { commits: [], silentAddition: true },
+ { isLoadingCommits: true, commits: [] },
+ [{ type: types.SET_COMMITS_SILENT, payload: [] }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('createContextCommits', () => {
+ it('calls API to create context commits', done => {
+ mock.onPost(contextCommitEndpoint).reply(200, {});
+
+ testAction(createContextCommits, { commits: [] }, {}, [], [], done);
+
+ createContextCommits(
+ { state: { projectId, mergeRequestIid }, commit: () => null },
+ { commits: [] },
+ )
+ .then(() => {
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('fetchContextCommits', () => {
+ beforeEach(() => {
+ mock
+ .onGet(
+ `/api/${gon.api_version}/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits`,
+ )
+ .reply(200, [dummyCommit]);
+ });
+ it('commits FETCH_CONTEXT_COMMITS', done => {
+ const contextCommit = { ...dummyCommit, isSelected: true };
+ testAction(
+ fetchContextCommits,
+ null,
+ {
+ mergeRequestIid,
+ projectId: projectPath,
+ isLoadingContextCommits: false,
+ contextCommitsLoadingError: false,
+ commits: [],
+ },
+ [{ type: types.FETCH_CONTEXT_COMMITS }],
+ [
+ { type: 'setContextCommits', payload: [contextCommit] },
+ { type: 'setCommits', payload: { commits: [contextCommit], silentAddition: true } },
+ { type: 'setSelectedCommits', payload: [contextCommit] },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('setContextCommits', () => {
+ it('commits SET_CONTEXT_COMMITS', done => {
+ testAction(
+ setContextCommits,
+ { data: [] },
+ { contextCommits: [], isLoadingContextCommits: false },
+ [{ type: types.SET_CONTEXT_COMMITS, payload: { data: [] } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('removeContextCommits', () => {
+ beforeEach(() => {
+ mock
+ .onDelete('/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits')
+ .reply(204);
+ });
+ it('calls API to remove context commits', done => {
+ testAction(
+ removeContextCommits,
+ { forceReload: false },
+ { mergeRequestIid, projectId, toRemoveCommits: [] },
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setSelectedCommits', () => {
+ it('commits SET_SELECTED_COMMITS', done => {
+ testAction(
+ setSelectedCommits,
+ [dummyCommit],
+ { selectedCommits: [] },
+ [{ type: types.SET_SELECTED_COMMITS, payload: [dummyCommit] }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setSearchText', () => {
+ it('commits SET_SEARCH_TEXT', done => {
+ const searchText = 'Dummy Text';
+ testAction(
+ setSearchText,
+ searchText,
+ { searchText: '' },
+ [{ type: types.SET_SEARCH_TEXT, payload: searchText }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setToRemoveCommits', () => {
+ it('commits SET_TO_REMOVE_COMMITS', done => {
+ const commitId = 'abcde';
+
+ testAction(
+ setToRemoveCommits,
+ [commitId],
+ { toRemoveCommits: [] },
+ [{ type: types.SET_TO_REMOVE_COMMITS, payload: [commitId] }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('resetModalState', () => {
+ it('commits RESET_MODAL_STATE', done => {
+ const commitId = 'abcde';
+
+ testAction(
+ resetModalState,
+ null,
+ { toRemoveCommits: [commitId] },
+ [{ type: types.RESET_MODAL_STATE }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/add_context_commits_modal/store/mutations_spec.js b/spec/frontend/add_context_commits_modal/store/mutations_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..dfb40e3207112ed2229df13bd69d45ba893b8883
--- /dev/null
+++ b/spec/frontend/add_context_commits_modal/store/mutations_spec.js
@@ -0,0 +1,156 @@
+import mutations from '~/add_context_commits_modal/store/mutations';
+import * as types from '~/add_context_commits_modal/store/mutation_types';
+import { TEST_HOST } from 'helpers/test_constants';
+import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit';
+
+describe('AddContextCommitsModalStoreMutations', () => {
+ const { commit } = getDiffWithCommit();
+ describe('SET_BASE_CONFIG', () => {
+ it('should set contextCommitsPath, mergeRequestIid and projectId', () => {
+ const state = {};
+ const contextCommitsPath = `${TEST_HOST}/gitlab-org/gitlab/-/merge_requests/1/context_commits.json`;
+ const mergeRequestIid = 1;
+ const projectId = 1;
+
+ mutations[types.SET_BASE_CONFIG](state, { contextCommitsPath, mergeRequestIid, projectId });
+
+ expect(state.contextCommitsPath).toEqual(contextCommitsPath);
+ expect(state.mergeRequestIid).toEqual(mergeRequestIid);
+ expect(state.projectId).toEqual(projectId);
+ });
+ });
+
+ describe('SET_TABINDEX', () => {
+ it('sets tabIndex to specific index', () => {
+ const state = { tabIndex: 0 };
+
+ mutations[types.SET_TABINDEX](state, 1);
+
+ expect(state.tabIndex).toBe(1);
+ });
+ });
+
+ describe('FETCH_COMMITS', () => {
+ it('sets isLoadingCommits to true', () => {
+ const state = { isLoadingCommits: false };
+
+ mutations[types.FETCH_COMMITS](state);
+
+ expect(state.isLoadingCommits).toBe(true);
+ });
+ });
+
+ describe('SET_COMMITS', () => {
+ it('sets commits to passed data and stop loading', () => {
+ const state = { commits: [], isLoadingCommits: true };
+
+ mutations[types.SET_COMMITS](state, [commit]);
+
+ expect(state.commits).toStrictEqual([commit]);
+ expect(state.isLoadingCommits).toBe(false);
+ });
+ });
+
+ describe('SET_COMMITS_SILENT', () => {
+ it('sets commits to passed data and loading continues', () => {
+ const state = { commits: [], isLoadingCommits: true };
+
+ mutations[types.SET_COMMITS_SILENT](state, [commit]);
+
+ expect(state.commits).toStrictEqual([commit]);
+ expect(state.isLoadingCommits).toBe(true);
+ });
+ });
+
+ describe('FETCH_COMMITS_ERROR', () => {
+ it('sets commitsLoadingError to true', () => {
+ const state = { commitsLoadingError: false };
+
+ mutations[types.FETCH_COMMITS_ERROR](state);
+
+ expect(state.commitsLoadingError).toBe(true);
+ });
+ });
+
+ describe('FETCH_CONTEXT_COMMITS', () => {
+ it('sets isLoadingContextCommits to true', () => {
+ const state = { isLoadingContextCommits: false };
+
+ mutations[types.FETCH_CONTEXT_COMMITS](state);
+
+ expect(state.isLoadingContextCommits).toBe(true);
+ });
+ });
+
+ describe('SET_CONTEXT_COMMITS', () => {
+ it('sets contextCommit to passed data and stop loading', () => {
+ const state = { contextCommits: [], isLoadingContextCommits: true };
+
+ mutations[types.SET_CONTEXT_COMMITS](state, [commit]);
+
+ expect(state.contextCommits).toStrictEqual([commit]);
+ expect(state.isLoadingContextCommits).toBe(false);
+ });
+ });
+
+ describe('FETCH_CONTEXT_COMMITS_ERROR', () => {
+ it('sets contextCommitsLoadingError to true', () => {
+ const state = { contextCommitsLoadingError: false };
+
+ mutations[types.FETCH_CONTEXT_COMMITS_ERROR](state);
+
+ expect(state.contextCommitsLoadingError).toBe(true);
+ });
+ });
+
+ describe('SET_SELECTED_COMMITS', () => {
+ it('sets selectedCommits to specified value', () => {
+ const state = { selectedCommits: [] };
+
+ mutations[types.SET_SELECTED_COMMITS](state, [commit]);
+
+ expect(state.selectedCommits).toStrictEqual([commit]);
+ });
+ });
+
+ describe('SET_SEARCH_TEXT', () => {
+ it('sets searchText to specified value', () => {
+ const searchText = 'Test';
+ const state = { searchText: '' };
+
+ mutations[types.SET_SEARCH_TEXT](state, searchText);
+
+ expect(state.searchText).toBe(searchText);
+ });
+ });
+
+ describe('SET_TO_REMOVE_COMMITS', () => {
+ it('sets searchText to specified value', () => {
+ const state = { toRemoveCommits: [] };
+
+ mutations[types.SET_TO_REMOVE_COMMITS](state, [commit.short_id]);
+
+ expect(state.toRemoveCommits).toStrictEqual([commit.short_id]);
+ });
+ });
+
+ describe('RESET_MODAL_STATE', () => {
+ it('sets searchText to specified value', () => {
+ const state = {
+ commits: [commit],
+ contextCommits: [commit],
+ selectedCommits: [commit],
+ toRemoveCommits: [commit.short_id],
+ searchText: 'Test',
+ };
+
+ mutations[types.RESET_MODAL_STATE](state);
+
+ expect(state.commits).toStrictEqual([]);
+ expect(state.contextCommits).toStrictEqual([]);
+ expect(state.selectedCommits).toStrictEqual([]);
+ expect(state.toRemoveCommits).toStrictEqual([]);
+ expect(state.searchText).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 0569254e4b8a6f20cc18fac5d2bdcd0afbd541b2..15485d9e38f4563a344a7de17289d6ebbca11e6f 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -667,6 +667,79 @@ describe('Api', () => {
});
});
+ describe('createContextCommits', () => {
+ it('creates a new context commit', done => {
+ const projectPath = 'abc';
+ const mergeRequestId = '123456';
+ const commitsData = ['abcdefg'];
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`;
+ const expectedData = {
+ commits: commitsData,
+ };
+
+ jest.spyOn(axios, 'post');
+
+ mock.onPost(expectedUrl).replyOnce(200, [
+ {
+ id: 'abcdefghijklmnop',
+ short_id: 'abcdefg',
+ title: 'Dummy commit',
+ },
+ ]);
+
+ Api.createContextCommits(projectPath, mergeRequestId, expectedData)
+ .then(({ data }) => {
+ expect(data[0].title).toBe('Dummy commit');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('allContextCommits', () => {
+ it('gets all context commits', done => {
+ const projectPath = 'abc';
+ const mergeRequestId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`;
+
+ jest.spyOn(axios, 'get');
+
+ mock
+ .onGet(expectedUrl)
+ .replyOnce(200, [{ id: 'abcdef', short_id: 'abcdefghi', title: 'Dummy commit title' }]);
+
+ Api.allContextCommits(projectPath, mergeRequestId)
+ .then(({ data }) => {
+ expect(data[0].title).toBe('Dummy commit title');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('removeContextCommits', () => {
+ it('removes context commits', done => {
+ const projectPath = 'abc';
+ const mergeRequestId = '123456';
+ const commitsData = ['abcdefg'];
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`;
+ const expectedData = {
+ commits: commitsData,
+ };
+
+ jest.spyOn(axios, 'delete');
+
+ mock.onDelete(expectedUrl).replyOnce(204);
+
+ Api.removeContextCommits(projectPath, mergeRequestId, expectedData)
+ .then(() => {
+ expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
describe('release-related methods', () => {
const dummyProjectPath = 'gitlab-org/gitlab';
const dummyTagName = 'v1.3';