Commit 3256783b authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'add-filters-to-code-analytics' into 'master'

Add filters to code analytics

See merge request gitlab-org/gitlab!18272
parents 19062344 35f97a86
<script> <script>
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import FileQuantityDropdown from './file_quantity_dropdown.vue';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { PROJECTS_PER_PAGE, DEFAULT_FILE_QUANTITY } from '../constants';
import createStore from '../store'; import createStore from '../store';
export default { export default {
...@@ -7,6 +13,9 @@ export default { ...@@ -7,6 +13,9 @@ export default {
store: createStore(), store: createStore(),
components: { components: {
GlEmptyState, GlEmptyState,
GroupsDropdownFilter,
ProjectsDropdownFilter,
FileQuantityDropdown,
}, },
props: { props: {
emptyStateSvgPath: { emptyStateSvgPath: {
...@@ -14,17 +23,87 @@ export default { ...@@ -14,17 +23,87 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
multiProjectSelect: false,
groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE,
},
projectsQueryParams: {
per_page: PROJECTS_PER_PAGE,
with_shared: false,
order_by: 'last_activity_at',
},
};
},
computed: {
...mapState(['selectedGroup', 'selectedProject', 'selectedFileQuantity']),
displayFileQuantityFilter() {
return this.selectedGroup && this.selectedProject;
},
},
mounted() {
this.setSelectedFileQuantity(DEFAULT_FILE_QUANTITY);
},
methods: {
...mapActions(['setSelectedGroup', 'setSelectedProject', 'setSelectedFileQuantity']),
onGroupSelect(group) {
this.setSelectedGroup(group);
},
onProjectSelect(projects) {
const project = projects.length ? projects[0] : null;
this.setSelectedProject(project);
},
onFileQuantitySelect(fileQuantity) {
this.setSelectedFileQuantity(fileQuantity);
},
},
}; };
</script> </script>
<template> <template>
<gl-empty-state <div>
:title="__('Identify the most frequently changed files in your repository')" <div class="page-title-holder d-flex align-items-center">
:description=" <h3 class="page-title">{{ __('Code Analytics') }}</h3>
__( </div>
'Identify areas of the codebase associated with a lot of churn, which can indicate potential code hotspots.', <div class="mw-100">
) <div
" class="mt-3 py-2 px-3 d-flex bg-gray-light border-top border-bottom flex-column flex-md-row justify-content-start"
:svg-path="emptyStateSvgPath" >
/> <groups-dropdown-filter
class="dropdown-select"
:query-params="groupsQueryParams"
@selected="onGroupSelect"
/>
<projects-dropdown-filter
v-if="selectedGroup"
:key="selectedGroup.id"
class="ml-md-1 mt-1 mt-md-0 dropdown-select"
:group-id="selectedGroup.id"
:query-params="projectsQueryParams"
:multi-select="multiProjectSelect"
@selected="onProjectSelect"
/>
<div
v-if="displayFileQuantityFilter"
class="ml-0 ml-md-auto mt-2 mt-md-0 d-flex flex-column flex-md-row align-items-md-center justify-content-md-end"
>
<label class="text-bold mb-0 mr-2">{{ s__('CodeAnalytics|Max files') }}</label>
<file-quantity-dropdown
:selected="selectedFileQuantity"
@selected="onFileQuantitySelect"
/>
</div>
</div>
</div>
<gl-empty-state
:title="__('Identify the most frequently changed files in your repository')"
:description="
__(
'Identify areas of the codebase associated with a lot of churn, which can indicate potential code hotspots.',
)
"
:svg-path="emptyStateSvgPath"
/>
</div>
</template> </template>
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { FILE_QUANTITIES } from '../constants';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
props: {
selected: {
type: Number,
required: true,
},
fileQuantityOptions: {
type: Array,
required: false,
default: () => FILE_QUANTITIES,
},
},
computed: {
selectedFileQuantityText() {
return this.selected.toString();
},
},
methods: {
onSelect(fileQuantity) {
this.$emit('selected', fileQuantity);
},
},
};
</script>
<template>
<gl-dropdown
toggle-class="dropdown-menu-toggle w-100"
menu-class="w-100 mw-100"
:text="selectedFileQuantityText"
>
<gl-dropdown-item
v-for="option in fileQuantityOptions"
:key="option"
class="w-100"
@click="onSelect(option)"
>{{ option }}</gl-dropdown-item
>
</gl-dropdown>
</template>
export const PROJECTS_PER_PAGE = 50;
export const FILE_QUANTITIES = [25, 50, 100, 250, 500];
export const DEFAULT_FILE_QUANTITY = FILE_QUANTITIES[2];
import * as types from './mutation_types';
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProject = ({ commit }, project) =>
commit(types.SET_SELECTED_PROJECT, project);
export const setSelectedFileQuantity = ({ commit }, fileQuantity) =>
commit(types.SET_SELECTED_FILE_QUANTITY, fileQuantity);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex); Vue.use(Vuex);
export default () => new Vuex.Store({}); export default () =>
new Vuex.Store({
actions,
mutations,
state,
});
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
export const SET_SELECTED_FILE_QUANTITY = 'SET_SELECTED_FILE_QUANTITY';
import * as types from './mutation_types';
export default {
[types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = group;
state.selectedProject = null;
},
[types.SET_SELECTED_PROJECT](state, project) {
state.selectedProject = project;
},
[types.SET_SELECTED_FILE_QUANTITY](state, fileQuantity) {
state.selectedFileQuantity = fileQuantity;
},
};
export default {
selectedGroup: null,
selectedProject: null,
selectedFileQuantity: null,
};
...@@ -2,7 +2,14 @@ import Vuex from 'vuex'; ...@@ -2,7 +2,14 @@ import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import Component from 'ee/analytics/code_analytics/components/app.vue'; import Component from 'ee/analytics/code_analytics/components/app.vue';
import GroupsDropdownFilter from 'ee/analytics/shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import FileQuantityDropdown from 'ee/analytics/code_analytics/components/file_quantity_dropdown.vue';
import { group, project, DEFAULT_FILE_QUANTITY } from '../mock_data';
const emptyStateTitle = 'Identify the most frequently changed files in your repository';
const emptyStateDescription =
'Identify areas of the codebase associated with a lot of churn, which can indicate potential code hotspots.';
const emptyStateSvgPath = 'path/to/empty/state'; const emptyStateSvgPath = 'path/to/empty/state';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -10,13 +17,14 @@ localVue.use(Vuex); ...@@ -10,13 +17,14 @@ localVue.use(Vuex);
let wrapper; let wrapper;
const createComponent = () => const createComponent = (opts = {}) =>
shallowMount(Component, { shallowMount(Component, {
localVue, localVue,
sync: false, sync: false,
propsData: { propsData: {
emptyStateSvgPath, emptyStateSvgPath,
}, },
...opts,
}); });
describe('Code Analytics component', () => { describe('Code Analytics component', () => {
...@@ -28,18 +36,120 @@ describe('Code Analytics component', () => { ...@@ -28,18 +36,120 @@ describe('Code Analytics component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('mounted', () => {
const actionSpies = {
setSelectedFileQuantity: jest.fn(),
};
beforeEach(() => {
wrapper = createComponent({ methods: actionSpies });
});
it('dispatches setSelectedFileQuantity with DEFAULT_FILE_QUANTITY', () => {
expect(actionSpies.setSelectedFileQuantity).toHaveBeenCalledWith(DEFAULT_FILE_QUANTITY);
});
});
describe('methods', () => {
describe('onProjectSelect', () => {
it('sets the project to null if no projects are submitted', () => {
wrapper.vm.onProjectSelect([]);
expect(wrapper.vm.$store.state.selectedProject).toBe(null);
});
it('sets the project correctly when submitted', () => {
wrapper.vm.onProjectSelect([project]);
expect(wrapper.vm.$store.state.selectedProject).toBe(project);
});
});
});
describe('displays the components as required', () => { describe('displays the components as required', () => {
it('displays an empty state', () => { describe('before a group has been selected', () => {
const emptyState = wrapper.find(GlEmptyState); it('displays an empty state', () => {
const emptyState = wrapper.find(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props('title')).toBe( expect(emptyState.exists()).toBeTruthy();
'Identify the most frequently changed files in your repository', expect(emptyState.props('title')).toBe(emptyStateTitle);
); expect(emptyState.props('description')).toBe(emptyStateDescription);
expect(emptyState.props('description')).toBe( expect(emptyState.props('svgPath')).toBe(emptyStateSvgPath);
'Identify areas of the codebase associated with a lot of churn, which can indicate potential code hotspots.', });
);
expect(emptyState.props('svgPath')).toBe(emptyStateSvgPath); it('shows the groups filter', () => {
expect(wrapper.find(GroupsDropdownFilter).exists()).toBeTruthy();
});
it('does not show the projects filter', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBeFalsy();
});
it('does not show the file quantity filter', () => {
expect(wrapper.find(FileQuantityDropdown).exists()).toBeFalsy();
});
});
describe('after a group has been selected', () => {
beforeEach(() => {
wrapper.vm.$store.state.selectedGroup = group;
});
describe('with no project selected', () => {
beforeEach(() => {
wrapper.vm.$store.state.selectedProject = null;
});
it('still displays an empty state', () => {
const emptyState = wrapper.find(GlEmptyState);
expect(emptyState.exists()).toBeTruthy();
expect(emptyState.props('title')).toBe(emptyStateTitle);
expect(emptyState.props('description')).toBe(emptyStateDescription);
expect(emptyState.props('svgPath')).toBe(emptyStateSvgPath);
});
it('still shows the groups filter', () => {
expect(wrapper.find(GroupsDropdownFilter).exists()).toBeTruthy();
});
it('shows the projects filter', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBeTruthy();
});
it('does not show the file quantity filter', () => {
expect(wrapper.find(FileQuantityDropdown).exists()).toBeFalsy();
});
});
describe('with a project selected', () => {
beforeEach(() => {
wrapper.vm.$store.state.selectedProject = project;
});
// This is until the empty state is replaced in a future iteration
// https://gitlab.com/gitlab-org/gitlab/merge_requests/18395
it('still displays an empty state', () => {
const emptyState = wrapper.find(GlEmptyState);
expect(emptyState.exists()).toBeTruthy();
expect(emptyState.props('title')).toBe(emptyStateTitle);
expect(emptyState.props('description')).toBe(emptyStateDescription);
expect(emptyState.props('svgPath')).toBe(emptyStateSvgPath);
});
it('still shows the groups filter', () => {
expect(wrapper.find(GroupsDropdownFilter).exists()).toBeTruthy();
});
it('shows the projects filter', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBeTruthy();
});
it('shows the file quantity filter', () => {
expect(wrapper.find(FileQuantityDropdown).exists()).toBeTruthy();
});
});
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import { GlDropdownItem } from '@gitlab/ui';
import FileQuantityDropdown from 'ee/analytics/code_analytics/components/file_quantity_dropdown.vue';
import { DEFAULT_FILE_QUANTITY } from '../mock_data';
describe('FileQuantityDropdown component', () => {
let wrapper;
const createComponent = (props = {}) =>
shallowMount(FileQuantityDropdown, {
propsData: {
selected: DEFAULT_FILE_QUANTITY,
...props,
},
});
const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
const findFirstDropdownElement = () => findDropdownElements().at(0);
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('the default behaviour acts as expected', () => {
it('renders the default dropdown items', () => {
expect(findDropdownElements().length).toBe(5);
});
it('displays the correct label for the first dropdown item', () => {
expect(findFirstDropdownElement().text()).toBe('25');
});
it('emits the "selected" event with the selected item value', () => {
findFirstDropdownElement().vm.$emit('click');
expect(wrapper.emitted().selected[0]).toEqual([25]);
});
});
describe('the component renders the correct dropdown text when selected is passed through', () => {
beforeEach(() => {
wrapper = createComponent({ selected: 250, fileQuantityOptions: [100, 250, 500] });
});
afterEach(() => {
wrapper.destroy();
});
it('renders the default dropdown items', () => {
expect(findDropdownElements().length).toBe(3);
});
it('displays the correct label for the first dropdown item', () => {
expect(findFirstDropdownElement().text()).toBe('100');
});
it('emits the "selected" event with the selected item value', () => {
findFirstDropdownElement().vm.$emit('click');
expect(wrapper.emitted().selected[0]).toEqual([100]);
});
});
});
export const group = {
id: 1,
name: 'foo',
path: 'foo',
avatar_url: 'host/images/group/image.svg',
};
export const project = {
id: 1,
name: 'bar',
path: 'bar',
avatar_url: 'host/images/project/image.svg',
};
export const DEFAULT_FILE_QUANTITY = 100;
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/code_analytics/store/actions';
import * as types from 'ee/analytics/code_analytics/store/mutation_types';
import { group, project } from '../mock_data';
describe('Cycle analytics actions', () => {
let state;
beforeEach(() => {
state = {};
});
it.each`
action | type | stateKey | payload
${'setSelectedGroup'} | ${types.SET_SELECTED_GROUP} | ${'selectedGroup'} | ${group.name}
${'setSelectedProject'} | ${types.SET_SELECTED_PROJECT} | ${'selectedProject'} | ${project}
${'setSelectedFileQuantity'} | ${types.SET_SELECTED_FILE_QUANTITY} | ${'selectedFileQuantity'} | ${250}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
testAction(
actions[action],
payload,
state,
[
{
type,
payload,
},
],
[],
);
});
});
import mutations from 'ee/analytics/code_analytics/store/mutations';
import * as types from 'ee/analytics/code_analytics/store/mutation_types';
import { group, project } from '../mock_data';
describe('Cycle analytics mutations', () => {
let state;
beforeEach(() => {
state = {};
});
afterEach(() => {
state = {};
});
it.each`
mutation | payload | expectedState
${types.SET_SELECTED_GROUP} | ${group.name} | ${{ selectedGroup: group.name, selectedProject: null }}
${types.SET_SELECTED_PROJECT} | ${project} | ${{ selectedProject: project }}
${types.SET_SELECTED_FILE_QUANTITY} | ${250} | ${{ selectedFileQuantity: 250 }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
mutations[mutation](state, payload);
expect(state).toMatchObject(expectedState);
},
);
});
...@@ -4026,6 +4026,9 @@ msgstr "" ...@@ -4026,6 +4026,9 @@ msgstr ""
msgid "Code owners" msgid "Code owners"
msgstr "" msgstr ""
msgid "CodeAnalytics|Max files"
msgstr ""
msgid "CodeOwner|Pattern" msgid "CodeOwner|Pattern"
msgstr "" 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