Commit 3c9b5572 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'mw-productivity-analytics-filter-bar' into 'master'

Add filters for Productivity Analytics

See merge request gitlab-org/gitlab-ee!14793
parents 48d7a0c2 4297e003
- type = local_assigns.fetch(:type)
- board = local_assigns.fetch(:board, nil)
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
- user_can_admin_list = board && can?(current_user, :admin_list, board.parent)
.issues-filters{ class: ("w-100" if type == :boards_modal) }
......@@ -155,5 +156,5 @@
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn
- elsif type != :boards_modal
- elsif is_not_boards_modal_or_productivity_analytics
= render 'shared/issuable/sort_dropdown'
<script>
import { mapState, mapActions } from 'vuex';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
export default {
components: {
GroupsDropdownFilter,
ProjectsDropdownFilter,
},
data() {
return {
groupId: null,
};
},
computed: {
...mapState('filters', ['groupNamespace']),
showProjectsDropdownFilter() {
return Boolean(this.groupId);
},
},
methods: {
...mapActions('filters', ['setGroupNamespace', 'setProjectPath']),
onGroupSelected({ id, full_path }) {
this.groupId = id;
this.setGroupNamespace(full_path);
this.$emit('groupSelected', full_path);
},
onProjectsSelected([selectedProject]) {
const { path } = selectedProject;
this.setProjectPath(path);
this.$emit('projectSelected', { namespacePath: this.groupNamespace, project: path });
},
},
};
</script>
<template>
<div class="d-flex flex-column flex-md-row">
<groups-dropdown-filter class="group-select" @selected="onGroupSelected" />
<projects-dropdown-filter
v-if="showProjectsDropdownFilter"
:key="groupId"
class="project-select"
:group-id="groupId"
@selected="onProjectsSelected"
/>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import DateRangeDropdown from '../../shared/components/date_range_dropdown.vue';
export default {
components: {
DateRangeDropdown,
},
computed: {
...mapState('filters', ['groupNamespace', 'daysInPast']),
},
methods: {
...mapActions('filters', ['setDaysInPast']),
},
};
</script>
<template>
<div
v-if="groupNamespace"
class="d-flex flex-column flex-md-row align-items-md-center justify-content-md-end"
>
<label class="mb-0 mr-1">{{ s__('Analytics|Timeframe') }}</label>
<date-range-dropdown :default-selected="daysInPast" @selected="setDaysInPast" />
</div>
</template>
import ProductivityAnalyticsFilteredSearchTokenKeys from './productivity_analytics_filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import store from './store';
export default class FilteredSearchProductivityAnalytics extends FilteredSearchManager {
constructor({ isGroup = true }) {
super({
page: 'productivity_analytics',
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
isGroup,
filteredSearchTokenKeys: ProductivityAnalyticsFilteredSearchTokenKeys,
});
this.isHandledAsync = true;
}
/**
* Updates filters in productivity analytics store
*/
updateObject = path => {
store.dispatch('filters/setPath', path);
};
}
import Vue from 'vue';
import Api from '~/api';
import store from './store';
import FilterDropdowns from './components/filter_dropdowns.vue';
import TimeFrameDropdown from './components/timeframe_dropdown.vue';
import ProductivityAnalyticsApp from './components/app.vue';
import FilteredSearchProductivityAnalytics from './filtered_search_productivity_analytics';
export default function(el) {
if (!el) {
return false;
}
export default () => {
const container = document.getElementById('js-productivity-analytics');
const groupProjectSelectContainer = container.querySelector('.js-group-project-select-container');
const searchBarContainer = container.querySelector('.js-search-bar');
return new Vue({
el,
components: {
ProductivityAnalyticsApp,
// we need to store the HTML content so we can reset it later
const issueFilterHtml = searchBarContainer.querySelector('.issues-filters').innerHTML;
const timeframeContainer = container.querySelector('.js-timeframe-container');
const appContainer = container.querySelector('.js-productivity-analytics-app-container');
let filterManager;
// eslint-disable-next-line no-new
new Vue({
el: groupProjectSelectContainer,
store,
methods: {
onGroupSelected(namespacePath) {
this.initFilteredSearch(namespacePath);
},
onProjectSelected({ namespacePath, project }) {
this.initFilteredSearch(namespacePath, project);
},
initFilteredSearch(namespacePath, project = '') {
// let's unbind attached event handlers first and reset the template
if (filterManager) {
filterManager.cleanup();
searchBarContainer.innerHTML = issueFilterHtml;
}
searchBarContainer.classList.remove('hide');
const filteredSearchInput = searchBarContainer.querySelector('.filtered-search');
const labelsEndpoint = this.getLabelsEndpoint(namespacePath, project);
const milestonesEndpoint = this.getMilestonesEndpoint(namespacePath, project);
filteredSearchInput.setAttribute('data-group-id', namespacePath);
if (project) {
filteredSearchInput.setAttribute('data-project-id', project);
}
filteredSearchInput.setAttribute('data-labels-endpoint', labelsEndpoint);
filteredSearchInput.setAttribute('data-milestones-endpoint', milestonesEndpoint);
filterManager = new FilteredSearchProductivityAnalytics({ isGroup: false });
filterManager.setup();
},
getLabelsEndpoint(namespacePath, projectPath) {
if (projectPath) {
return Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
}
return Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
},
getMilestonesEndpoint(namespacePath, projectPath) {
if (projectPath) {
return `/${namespacePath}/${projectPath}/-/milestones`;
}
return `/groups/${namespacePath}/-/milestones`;
},
},
render(h) {
return h(ProductivityAnalyticsApp, {
props: {},
return h(FilterDropdowns, {
on: {
groupSelected: this.onGroupSelected,
projectSelected: this.onProjectSelected,
},
});
},
});
}
// eslint-disable-next-line no-new
new Vue({
el: timeframeContainer,
store,
render(h) {
return h(TimeFrameDropdown, {});
},
});
// eslint-disable-next-line no-new
new Vue({
el: appContainer,
store,
render(h) {
return h(ProductivityAnalyticsApp, {});
},
});
};
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const tokenKeys = [
{
key: 'author',
type: 'string',
param: 'username',
symbol: '@',
icon: 'pencil',
tag: '@author',
},
{
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
icon: 'clock',
tag: '%milestone',
},
{
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'labels',
tag: '~label',
},
];
const ProductivityAnalyticsFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
export default ProductivityAnalyticsFilteredSearchTokenKeys;
import Vue from 'vue';
import Vuex from 'vuex';
import filters from './modules/filters/index';
Vue.use(Vuex);
const createStore = () =>
new Vuex.Store({
modules: {
filters,
},
});
export default createStore();
import * as types from './mutation_types';
export const setGroupNamespace = ({ commit }, groupNamespace) => {
commit(types.SET_GROUP_NAMESPACE, groupNamespace);
};
export const setProjectPath = ({ commit }, projectPath) => {
commit(types.SET_PROJECT_PATH, projectPath);
};
export const setPath = ({ commit }, path) => {
commit(types.SET_PATH, path);
};
export const setDaysInPast = ({ commit }, days) => {
commit(types.SET_DAYS_IN_PAST, days);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
export default {
namespaced: true,
state: state(),
mutations,
actions,
};
export const SET_GROUP_NAMESPACE = 'SET_GROUP_NAMESPACE';
export const SET_PROJECT_PATH = 'SET_PROJECT_PATH';
export const SET_PATH = 'SET_PATH';
export const SET_DAYS_IN_PAST = 'SET_DAYS_IN_PAST';
import * as types from './mutation_types';
export default {
[types.SET_GROUP_NAMESPACE](state, groupNamespace) {
state.groupNamespace = groupNamespace;
state.projectPath = null;
},
[types.SET_PROJECT_PATH](state, projectPath) {
state.projectPath = projectPath;
},
[types.SET_PATH](state, path) {
state.filters = path;
},
[types.SET_DAYS_IN_PAST](state, daysInPast) {
state.daysInPast = daysInPast;
},
};
export default () => ({
groupNamespace: null,
projectPath: null,
filters: '',
daysInPast: 90,
});
import initProductivityAnalyticsApp from 'ee/analytics/productivity_analytics';
document.addEventListener('DOMContentLoaded', () => {
const containerEl = document.getElementById('js-productivity-analytics-container');
initProductivityAnalyticsApp(containerEl);
initProductivityAnalyticsApp();
});
.dropdown-container {
flex: 0 0 25%;
@include media-breakpoint-down(sm) {
.dropdown {
margin-bottom: $gl-padding-8;
}
}
@include media-breakpoint-up(md) {
.group-select,
.project-select {
margin-right: $gl-padding;
flex: 0 1 50%;
}
}
}
.filter-container {
flex: 1 1 50%;
@include media-breakpoint-down(md) {
.filtered-search-box {
margin-bottom: 10px;
}
.issues-details-filters {
padding: 0;
background: transparent;
}
}
}
.mr-table {
@include media-breakpoint-down(md) {
.gl-responsive-table-row {
......
- page_title _('Productivity Analytics')
-# = render 'shared/issuable/search_bar', type: :issues, show_sorting_dropdown: false
#js-productivity-analytics-container
#js-productivity-analytics
.row-content-block.second-block.d-flex.flex-column.flex-md-row
.dropdown-container
.js-group-project-select-container
.js-search-bar.filter-container.hide
= render 'shared/issuable/search_bar', type: :productivity_analytics
.dropdown-container
.js-timeframe-container
.js-productivity-analytics-app-container
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import FilterDropdowns from 'ee/analytics/productivity_analytics/components/filter_dropdowns.vue';
import GroupsDropdownFilter from 'ee/analytics/shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import store from 'ee/analytics/productivity_analytics/store';
import resetStore from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FilterDropdowns component', () => {
let wrapper;
const actionSpies = {
setGroupNamespace: jest.fn(),
setProjectPath: jest.fn(),
};
const groupNamespace = 'gitlab-org';
const projectPath = 'gitlab-test';
beforeEach(() => {
wrapper = shallowMount(localVue.extend(FilterDropdowns), {
localVue,
store,
sync: false,
propsData: {},
methods: {
...actionSpies,
},
});
});
afterEach(() => {
wrapper.destroy();
resetStore(store);
});
describe('template', () => {
it('renders the groups dropdown', () => {
expect(wrapper.find(GroupsDropdownFilter).exists()).toBe(true);
});
describe('without a group selected', () => {
beforeEach(() => {
wrapper.vm.groupId = null;
});
it('does not render the projects dropdown', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBe(false);
});
});
describe('with a group selected', () => {
beforeEach(() => {
wrapper.vm.groupId = 1;
});
it('renders the projects dropdown', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBe(true);
});
});
});
describe('methods', () => {
describe('onGroupSelected', () => {
beforeEach(() => {
wrapper.vm.onGroupSelected({ id: 1, full_path: groupNamespace });
});
it('updates the groupId and invokes setGroupNamespace action', () => {
expect(wrapper.vm.groupId).toBe(1);
expect(actionSpies.setGroupNamespace).toHaveBeenCalledWith(groupNamespace);
});
it('emits the "groupSelected" event', () => {
expect(wrapper.emitted().groupSelected[0][0]).toBe(groupNamespace);
});
});
describe('onProjectsSelected', () => {
beforeEach(() => {
store.state.filters.groupNamespace = groupNamespace;
wrapper.vm.onProjectsSelected([{ id: 1, path: `${projectPath}` }]);
});
it('invokes setProjectPath action', () => {
expect(actionSpies.setProjectPath).toHaveBeenCalledWith(projectPath);
});
it('emits the "projectSelected" event', () => {
expect(wrapper.emitted().projectSelected[0][0]).toEqual({
namespacePath: groupNamespace,
project: projectPath,
});
});
});
});
});
import filterState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
const resetStore = store => {
const newState = {
filters: filterState(),
};
store.replaceState(newState);
};
export default resetStore;
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/productivity_analytics/store/modules/filters/actions';
import * as types from 'ee/analytics/productivity_analytics/store/modules/filters/mutation_types';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
describe('Productivity analytics filter actions', () => {
describe('setGroupNamespace', () => {
it('commits the SET_GROUP_NAMESPACE mutation', done =>
testAction(
actions.setGroupNamespace,
'gitlab-org',
getInitialState(),
[
{
type: types.SET_GROUP_NAMESPACE,
payload: 'gitlab-org',
},
],
[],
done,
));
});
describe('setProjectPath', () => {
it('commits the SET_PROJECT_PATH mutation', done =>
testAction(
actions.setProjectPath,
'gitlab-test',
getInitialState(),
[
{
type: types.SET_PROJECT_PATH,
payload: 'gitlab-test',
},
],
[],
done,
));
});
describe('setPath', () => {
it('commits the SET_PATH mutation', done =>
testAction(
actions.setPath,
'author_username=root',
getInitialState(),
[
{
type: types.SET_PATH,
payload: 'author_username=root',
},
],
[],
done,
));
});
describe('setDaysInPast', () => {
it('commits the SET_DAYS_IN_PAST mutation', done =>
testAction(
actions.setDaysInPast,
90,
getInitialState(),
[
{
type: types.SET_DAYS_IN_PAST,
payload: 90,
},
],
[],
done,
));
});
});
import * as types from 'ee/analytics/productivity_analytics/store/modules/filters/mutation_types';
import mutations from 'ee/analytics/productivity_analytics/store/modules/filters/mutations';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
describe('Productivity analytics filter mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe(types.SET_GROUP_NAMESPACE, () => {
it('sets the groupNamespace', () => {
const groupNamespace = 'gitlab-org';
mutations[types.SET_GROUP_NAMESPACE](state, groupNamespace);
expect(state.groupNamespace).toBe(groupNamespace);
});
});
describe(types.SET_PROJECT_PATH, () => {
it('sets the projectPath', () => {
const projectPath = 'gitlab-test';
mutations[types.SET_PROJECT_PATH](state, projectPath);
expect(state.projectPath).toBe(projectPath);
});
});
describe(types.SET_PATH, () => {
it('sets the filters string', () => {
const path = '?author_username=root&milestone_title=foo&label_name[]=labelxyz';
mutations[types.SET_PATH](state, path);
expect(state.filters).toBe(path);
});
});
describe(types.SET_DAYS_IN_PAST, () => {
it('sets the daysInPast', () => {
const daysInPast = 14;
mutations[types.SET_DAYS_IN_PAST](state, daysInPast);
expect(state.daysInPast).toBe(daysInPast);
});
});
});
......@@ -1483,6 +1483,9 @@ msgstr ""
msgid "Analytics"
msgstr ""
msgid "Analytics|Timeframe"
msgstr ""
msgid "Ancestors"
msgstr ""
......
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