diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js
index ad671ce93516ebffd12ba8c4ef4635e6854d6c0a..b456baac612149579ee396a874686cc6f00f1b22 100644
--- a/app/assets/javascripts/pages/projects/commits/show/index.js
+++ b/app/assets/javascripts/pages/projects/commits/show/index.js
@@ -2,8 +2,11 @@ import CommitsList from '~/commits';
import GpgBadges from '~/gpg_badges';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import mountCommits from '~/projects/commits';
+
document.addEventListener('DOMContentLoaded', () => {
new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
GpgBadges.fetch();
+ mountCommits(document.getElementById('js-author-dropdown'));
});
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
new file mode 100644
index 0000000000000000000000000000000000000000..78f9389b80c1b7d223c2a5f7a7a8310700f4c9a8
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+ {{ __('Search by author') }}
+
+
+
+
+ {{ __('Any Author') }}
+
+
+
+ {{ author.name }}
+
+
+
+
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..6f85432a77ead92784b97b905a39614fdd1b3880
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import AuthorSelectApp from './components/author_select.vue';
+import store from './store';
+
+Vue.use(Vuex);
+
+export default el => {
+ if (!el) {
+ return null;
+ }
+
+ store.dispatch('setInitialData', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ render(h) {
+ return h(AuthorSelectApp, {
+ props: {
+ projectCommitsEl: document.querySelector('.js-project-commits-show'),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
new file mode 100644
index 0000000000000000000000000000000000000000..daeae071d6a8390a737202def9173850c63a5db5
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -0,0 +1,31 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ setInitialData({ commit }, data) {
+ commit(types.SET_INITIAL_DATA, data);
+ },
+ receiveAuthorsSuccess({ commit }, authors) {
+ commit(types.COMMITS_AUTHORS, authors);
+ },
+ receiveAuthorsError() {
+ createFlash(__('An error occurred fetching the project authors.'));
+ },
+ fetchAuthors({ dispatch, state }, author = null) {
+ const { projectId } = state;
+ const path = '/autocomplete/users.json';
+
+ return axios
+ .get(path, {
+ params: {
+ project_id: projectId,
+ active: true,
+ search: author,
+ },
+ })
+ .then(({ data }) => dispatch('receiveAuthorsSuccess', data))
+ .catch(() => dispatch('receiveAuthorsError'));
+ },
+};
diff --git a/app/assets/javascripts/projects/commits/store/index.js b/app/assets/javascripts/projects/commits/store/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e864ef5716e49477657a17070e81a08cbd29f39d
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/store/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () => ({
+ actions,
+ mutations,
+ state: state(),
+});
+
+export default new Vuex.Store(createStore());
diff --git a/app/assets/javascripts/projects/commits/store/mutation_types.js b/app/assets/javascripts/projects/commits/store/mutation_types.js
new file mode 100644
index 0000000000000000000000000000000000000000..0a6a5a0b9028072536fb6e5f256a4b3cdeedfd73
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/store/mutation_types.js
@@ -0,0 +1,2 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+export const COMMITS_AUTHORS = 'COMMITS_AUTHORS';
diff --git a/app/assets/javascripts/projects/commits/store/mutations.js b/app/assets/javascripts/projects/commits/store/mutations.js
new file mode 100644
index 0000000000000000000000000000000000000000..11f703c0946176750c992a99485bee00cbb0b1f5
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/store/mutations.js
@@ -0,0 +1,10 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_DATA](state, data) {
+ Object.assign(state, data);
+ },
+ [types.COMMITS_AUTHORS](state, data) {
+ state.commitsAuthors = data;
+ },
+};
diff --git a/app/assets/javascripts/projects/commits/store/state.js b/app/assets/javascripts/projects/commits/store/state.js
new file mode 100644
index 0000000000000000000000000000000000000000..f074708ffa25d608f90512826f2a00941425a006
--- /dev/null
+++ b/app/assets/javascripts/projects/commits/store/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ commitsPath: null,
+ projectId: null,
+ commitsAuthors: [],
+});
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index b6edadb05a9c3e3247d9ee98032b41228ed045d0..f746d7e6f691abd992bbea542462295665eae671 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -317,7 +317,10 @@
}
}
- .dropdown-item {
+ // Temporary fix to ensure tick is aligned
+ // Follow up Issue to remove after the GlNewDropdownItem component is fixed
+ // > https://gitlab.com/gitlab-org/gitlab/-/issues/213948
+ li:not(.gl-new-dropdown-item) .dropdown-item {
@include dropdown-link;
}
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 3f1d44a488aaafd19a61f32a3f5ad8bcf0e4be0d..7722a3523a1377dc167e690426f1f7fc60e07b8b 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -13,7 +13,8 @@
%ul.breadcrumb.repo-breadcrumb
= commits_breadcrumbs
- .tree-controls.d-none.d-sm-none.d-md-block<
+ #js-author-dropdown{ data: { 'commits_path': project_commits_path(@project), 'project_id': @project.id } }
+ .tree-controls.d-none.d-sm-none.d-md-block
- if @merge_request.present?
.control
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn'
diff --git a/changelogs/unreleased/14984-show-commits-by-author.yml b/changelogs/unreleased/14984-show-commits-by-author.yml
new file mode 100644
index 0000000000000000000000000000000000000000..89f90c074ca6feef5a0388d0798fa0a2f83f77f2
--- /dev/null
+++ b/changelogs/unreleased/14984-show-commits-by-author.yml
@@ -0,0 +1,5 @@
+---
+title: Add ability to filter commits by author
+merge_request: 28509
+author:
+type: added
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 58d2b4b9586028bd518f83497d2d39882eb79d7b..081c08909b1137334dced0759444829b1af35512 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1855,6 +1855,9 @@ msgstr ""
msgid "An error occurred fetching the dropdown data."
msgstr ""
+msgid "An error occurred fetching the project authors."
+msgstr ""
+
msgid "An error occurred previewing the blob"
msgstr ""
@@ -2173,6 +2176,9 @@ msgstr ""
msgid "Any"
msgstr ""
+msgid "Any Author"
+msgstr ""
+
msgid "Any Label"
msgstr ""
@@ -17691,6 +17697,9 @@ msgstr ""
msgid "Search branches and tags"
msgstr ""
+msgid "Search by author"
+msgstr ""
+
msgid "Search files"
msgstr ""
@@ -17851,6 +17860,9 @@ msgid_plural "SearchResults|wiki results"
msgstr[0] ""
msgstr[1] ""
+msgid "Searching by both author and message is currently not supported."
+msgstr ""
+
msgid "Seat Link"
msgstr ""
diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..dab91d8b37caa914362b43831e16ba456536fdcf
--- /dev/null
+++ b/spec/frontend/projects/commits/components/author_select_spec.js
@@ -0,0 +1,216 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import * as urlUtility from '~/lib/utils/url_utility';
+import AuthorSelect from '~/projects/commits/components/author_select.vue';
+import { createStore } from '~/projects/commits/store';
+import {
+ GlNewDropdown,
+ GlNewDropdownHeader,
+ GlSearchBoxByType,
+ GlNewDropdownItem,
+} from '@gitlab/ui';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const commitsPath = 'author/search/url';
+const currentAuthor = 'lorem';
+const authors = [
+ {
+ id: 1,
+ name: currentAuthor,
+ username: 'ipsum',
+ avatar_url: 'some/url',
+ },
+ {
+ id: 2,
+ name: 'lorem2',
+ username: 'ipsum2',
+ avatar_url: 'some/url/2',
+ },
+];
+
+describe('Author Select', () => {
+ let store;
+ let wrapper;
+
+ const createComponent = () => {
+ setFixtures(`
+
+ `);
+
+ wrapper = shallowMount(AuthorSelect, {
+ localVue,
+ store: new Vuex.Store(store),
+ propsData: {
+ projectCommitsEl: document.querySelector('.js-project-commits-show'),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = createStore();
+ store.actions.fetchAuthors = jest.fn();
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownContainer = () => wrapper.find({ ref: 'dropdownContainer' });
+ const findDropdown = () => wrapper.find(GlNewDropdown);
+ const findDropdownHeader = () => wrapper.find(GlNewDropdownHeader);
+ const findSearchBox = () => wrapper.find(GlSearchBoxByType);
+ const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem);
+
+ describe('user is searching via "filter by commit message"', () => {
+ it('disables dropdown container', () => {
+ wrapper.setData({ hasSearchParam: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDropdownContainer().attributes('disabled')).toBeFalsy();
+ });
+ });
+
+ it('has correct tooltip message', () => {
+ wrapper.setData({ hasSearchParam: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDropdownContainer().attributes('title')).toBe(
+ 'Searching by both author and message is currently not supported.',
+ );
+ });
+ });
+
+ it('disables dropdown', () => {
+ wrapper.setData({ hasSearchParam: false });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDropdown().attributes('disabled')).toBeFalsy();
+ });
+ });
+
+ it('hasSearchParam if user types a truthy string', () => {
+ wrapper.vm.setSearchParam('false');
+
+ expect(wrapper.vm.hasSearchParam).toBeTruthy();
+ });
+ });
+
+ describe('dropdown', () => {
+ it('displays correct default text', () => {
+ expect(findDropdown().attributes('text')).toBe('Author');
+ });
+
+ it('displays the current selected author', () => {
+ wrapper.setData({ currentAuthor });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDropdown().attributes('text')).toBe(currentAuthor);
+ });
+ });
+
+ it('displays correct header text', () => {
+ expect(findDropdownHeader().text()).toBe('Search by author');
+ });
+
+ it('does not have popover text by default', () => {
+ expect(wrapper.attributes('title')).not.toExist();
+ });
+ });
+
+ describe('dropdown search box', () => {
+ it('has correct placeholder', () => {
+ expect(findSearchBox().attributes('placeholder')).toBe('Search');
+ });
+
+ it('fetch authors on input change', () => {
+ const authorName = 'lorem';
+ findSearchBox().vm.$emit('input', authorName);
+
+ expect(store.actions.fetchAuthors).toHaveBeenCalledWith(
+ expect.anything(),
+ authorName,
+ undefined,
+ );
+ });
+ });
+
+ describe('dropdown list', () => {
+ beforeEach(() => {
+ store.state.commitsAuthors = authors;
+ store.state.commitsPath = commitsPath;
+ });
+
+ it('has a "Any Author" as the first list item', () => {
+ expect(
+ findDropdownItems()
+ .at(0)
+ .text(),
+ ).toBe('Any Author');
+ });
+
+ it('displays the project authors', () => {
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDropdownItems()).toHaveLength(authors.length + 1);
+ });
+ });
+
+ it('has the correct props', () => {
+ const [{ avatar_url, username }] = authors;
+ const result = {
+ avatarUrl: avatar_url,
+ secondaryText: username,
+ isChecked: true,
+ };
+
+ wrapper.setData({ currentAuthor });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(
+ findDropdownItems()
+ .at(1)
+ .props(),
+ ).toEqual(expect.objectContaining(result));
+ });
+ });
+
+ it("display the author's name", () => {
+ return wrapper.vm.$nextTick().then(() => {
+ expect(
+ findDropdownItems()
+ .at(1)
+ .text(),
+ ).toBe(currentAuthor);
+ });
+ });
+
+ it('passes selected author to redirectPath', () => {
+ const redirectToUrl = `${commitsPath}?author=${currentAuthor}`;
+ const spy = jest.spyOn(urlUtility, 'redirectTo');
+ spy.mockImplementation(() => 'mock');
+
+ findDropdownItems()
+ .at(1)
+ .vm.$emit('click');
+
+ expect(spy).toHaveBeenCalledWith(redirectToUrl);
+ });
+
+ it('does not pass any author to redirectPath', () => {
+ const redirectToUrl = commitsPath;
+ const spy = jest.spyOn(urlUtility, 'redirectTo');
+ spy.mockImplementation();
+
+ findDropdownItems()
+ .at(0)
+ .vm.$emit('click');
+ expect(spy).toHaveBeenCalledWith(redirectToUrl);
+ });
+ });
+});
diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c9945e1cc2755ecb8adfad9b2412230b0d778392
--- /dev/null
+++ b/spec/frontend/projects/commits/store/actions_spec.js
@@ -0,0 +1,69 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import * as types from '~/projects/commits/store/mutation_types';
+import testAction from 'helpers/vuex_action_helper';
+import actions from '~/projects/commits/store/actions';
+import createState from '~/projects/commits/store/state';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+describe('Project commits actions', () => {
+ let state;
+ let mock;
+
+ beforeEach(() => {
+ state = createState();
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setInitialData', () => {
+ it(`commits ${types.SET_INITIAL_DATA}`, () =>
+ testAction(actions.setInitialData, undefined, state, [{ type: types.SET_INITIAL_DATA }]));
+ });
+
+ describe('receiveAuthorsSuccess', () => {
+ it(`commits ${types.COMMITS_AUTHORS}`, () =>
+ testAction(actions.receiveAuthorsSuccess, undefined, state, [
+ { type: types.COMMITS_AUTHORS },
+ ]));
+ });
+
+ describe('shows a flash message when there is an error', () => {
+ it('creates a flash', () => {
+ const mockDispatchContext = { dispatch: () => {}, commit: () => {}, state };
+ actions.receiveAuthorsError(mockDispatchContext);
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith('An error occurred fetching the project authors.');
+ });
+ });
+
+ describe('fetchAuthors', () => {
+ it('dispatches request/receive', () => {
+ const path = '/autocomplete/users.json';
+ state.projectId = '8';
+ const data = [{ id: 1 }];
+
+ mock.onGet(path).replyOnce(200, data);
+ testAction(
+ actions.fetchAuthors,
+ null,
+ state,
+ [],
+ [{ type: 'receiveAuthorsSuccess', payload: data }],
+ );
+ });
+
+ it('dispatches request/receive on error', () => {
+ const path = '/autocomplete/users.json';
+ mock.onGet(path).replyOnce(500);
+
+ testAction(actions.fetchAuthors, null, state, [], [{ type: 'receiveAuthorsError' }]);
+ });
+ });
+});
diff --git a/spec/frontend/projects/commits/store/mutations_spec.js b/spec/frontend/projects/commits/store/mutations_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4fc346beb332b87b25900d8f682bea0470395538
--- /dev/null
+++ b/spec/frontend/projects/commits/store/mutations_spec.js
@@ -0,0 +1,43 @@
+import * as types from '~/projects/commits/store/mutation_types';
+import mutations from '~/projects/commits/store/mutations';
+import createState from '~/projects/commits/store/state';
+
+describe('Project commits mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState();
+ });
+
+ afterEach(() => {
+ state = null;
+ });
+
+ describe(`${types.SET_INITIAL_DATA}`, () => {
+ it('sets initial data', () => {
+ state.commitsPath = null;
+ state.projectId = null;
+ state.commitsAuthors = [];
+
+ const data = {
+ commitsPath: 'some/path',
+ projectId: '8',
+ };
+
+ mutations[types.SET_INITIAL_DATA](state, data);
+
+ expect(state).toEqual(expect.objectContaining(data));
+ });
+ });
+
+ describe(`${types.COMMITS_AUTHORS}`, () => {
+ it('sets commitsAuthors', () => {
+ const authors = [{ id: 1 }, { id: 2 }];
+ state.commitsAuthors = [];
+
+ mutations[types.COMMITS_AUTHORS](state, authors);
+
+ expect(state.commitsAuthors).toEqual(authors);
+ });
+ });
+});