Commit e686d934 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '14984-show-commits-by-author' into 'master'

Show commits by author

Closes #14984

See merge request gitlab-org/gitlab!28509
parents 3b3af0d8 5b3ebf1a
......@@ -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'));
});
<script>
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
import {
GlNewDropdown,
GlNewDropdownHeader,
GlNewDropdownItem,
GlSearchBoxByType,
GlNewDropdownDivider,
GlTooltipDirective,
} from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
const tooltipMessage = __('Searching by both author and message is currently not supported.');
export default {
name: 'AuthorSelect',
components: {
GlNewDropdown,
GlNewDropdownHeader,
GlNewDropdownItem,
GlSearchBoxByType,
GlNewDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
projectCommitsEl: {
type: HTMLDivElement,
required: true,
},
},
data() {
return {
hasSearchParam: false,
searchTerm: '',
authorInput: '',
currentAuthor: '',
};
},
computed: {
...mapState(['commitsPath', 'commitsAuthors']),
dropdownText() {
return this.currentAuthor || __('Author');
},
tooltipTitle() {
return this.hasSearchParam && tooltipMessage;
},
},
mounted() {
this.fetchAuthors();
const params = urlParamsToObject(window.location.search);
const { search: searchParam, author: authorParam } = params;
const commitsSearchInput = this.projectCommitsEl.querySelector('#commits-search');
if (authorParam) {
commitsSearchInput.setAttribute('disabled', true);
commitsSearchInput.setAttribute('data-toggle', 'tooltip');
commitsSearchInput.setAttribute('title', tooltipMessage);
this.currentAuthor = authorParam;
}
if (searchParam) {
this.hasSearchParam = true;
}
commitsSearchInput.addEventListener(
'keyup',
debounce(event => this.setSearchParam(event.target.value), 500), // keyup & time is to match effect of "filter by commit message"
);
},
methods: {
...mapActions(['fetchAuthors']),
selectAuthor(author) {
const { name: user } = author || {};
// Follow up issue "Remove usage of $.fadeIn from the codebase"
// > https://gitlab.com/gitlab-org/gitlab/-/issues/214395
// Follow up issue "Refactor commit list to a Vue Component"
// To resolving mixing Vue + Vanilla JS
// > https://gitlab.com/gitlab-org/gitlab/-/issues/214010
const commitListElement = this.projectCommitsEl.querySelector('#commits-list');
// To mimick effect of "filter by commit message"
commitListElement.style.opacity = 0.5;
commitListElement.style.transition = 'opacity 200ms';
if (!user) {
return redirectTo(this.commitsPath);
}
return redirectTo(`${this.commitsPath}?author=${user}`);
},
searchAuthors() {
this.fetchAuthors(this.authorInput);
},
setSearchParam(value) {
this.hasSearchParam = Boolean(value);
},
},
};
</script>
<template>
<div ref="dropdownContainer" v-gl-tooltip :title="tooltipTitle" :disabled="!hasSearchParam">
<gl-new-dropdown
:text="dropdownText"
:disabled="hasSearchParam"
class="gl-dropdown w-100 mt-2 mt-sm-0"
>
<gl-new-dropdown-header>
{{ __('Search by author') }}
</gl-new-dropdown-header>
<gl-new-dropdown-divider />
<gl-search-box-by-type
v-model.trim="authorInput"
class="m-2"
:placeholder="__('Search')"
@input="searchAuthors"
/>
<gl-new-dropdown-item :is-checked="!currentAuthor" @click="selectAuthor(null)">
{{ __('Any Author') }}
</gl-new-dropdown-item>
<gl-new-dropdown-divider />
<gl-new-dropdown-item
v-for="author in commitsAuthors"
:key="author.id"
:is-checked="author.name === currentAuthor"
:avatar-url="author.avatar_url"
:secondary-text="author.username"
@click="selectAuthor(author)"
>
{{ author.name }}
</gl-new-dropdown-item>
</gl-new-dropdown>
</div>
</template>
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'),
},
});
},
});
};
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'));
},
};
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());
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const COMMITS_AUTHORS = 'COMMITS_AUTHORS';
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;
},
};
export default () => ({
commitsPath: null,
projectId: null,
commitsAuthors: [],
});
......@@ -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;
}
......
......@@ -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'
......
---
title: Add ability to filter commits by author
merge_request: 28509
author:
type: added
......@@ -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 ""
......
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(`
<div class="js-project-commits-show">
<input id="commits-search" type="text" />
<div id="commits-list"></div>
</div>
`);
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);
});
});
});
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' }]);
});
});
});
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);
});
});
});
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