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 @@ + + + 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); + }); + }); +});