Commit c26222ee authored by Phil Hughes's avatar Phil Hughes

Merge branch '4949-replace-tablesorter-with-vue' into 'master'

Port Group member contribution analytics table to Vue

Closes #4949

See merge request gitlab-org/gitlab-ee!5269
parents e4c31f25 34dd86ae
......@@ -8,6 +8,3 @@ import 'vendor/jquery.atwho';
import 'vendor/jquery.scrollTo';
import 'jquery.waitforimages';
import 'select2/select2';
// EE-only
import 'vendor/jquery.tablesorter';
<script>
import { __ } from '~/locale';
import Flash from '~/flash';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import COLUMNS from '../constants';
import TableHeader from './table_header.vue';
import TableBody from './table_body.vue';
export default {
columns: COLUMNS,
components: {
LoadingIcon,
TableHeader,
TableBody,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: true,
};
},
computed: {
members() {
return this.store.members;
},
sortOrders() {
return this.store.sortOrders;
},
},
mounted() {
this.fetchContributedMembers();
},
methods: {
fetchContributedMembers() {
this.service
.getContributedMembers()
.then(res => res.data)
.then(members => {
this.store.setColumns(this.$options.columns);
this.store.setMembers(members);
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
Flash(__('Something went wrong while fetching group member contributions'));
});
},
handleColumnClick(columnName) {
this.store.sortMembers(columnName);
},
},
};
</script>
<template>
<div class="group-member-contributions-container">
<h3>{{ __('Contributions per group member') }}</h3>
<loading-icon
class="loading-animation prepend-top-20 append-bottom-20"
size="2"
v-if="isLoading"
:label="__('Loading contribution stats for group members')"
/>
<table
v-else
class="table gl-sortable"
>
<table-header
:columns="$options.columns"
:sort-orders="sortOrders"
@onColumnClick="handleColumnClick"
/>
<table-body
:rows="members"
/>
</table>
</div>
</template>
<script>
export default {
props: {
rows: {
type: Array,
required: true,
},
},
};
</script>
<template>
<tbody>
<tr
v-for="(row, index) of rows"
:key="index"
>
<td>
<strong>
<a :href="row.userWebUrl">{{ row.fullname }}</a>
</strong>
</td>
<td>{{ row.push }}</td>
<td>{{ row.issuesCreated }}</td>
<td>{{ row.issuesClosed }}</td>
<td>{{ row.mergeRequestsCreated }}</td>
<td>{{ row.mergeRequestsMerged }}</td>
<td>{{ row.totalEvents }}</td>
</tr>
</tbody>
</template>
<script>
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
columns: {
type: Array,
required: true,
},
sortOrders: {
type: Object,
required: true,
},
},
data() {
const columnIconMeta = this.columns.reduce(
(acc, column) => ({
...acc,
[column.name]: this.getColumnIconMeta(column.name, this.sortOrders),
}),
{},
);
return { columnIconMeta };
},
methods: {
getColumnIconMeta(columnName, sortOrders) {
const isAsc = sortOrders[columnName] > 0;
return {
sortIcon: isAsc ? 'angle-up' : 'angle-down',
iconTooltip: isAsc ? __('Ascending') : __('Descending'),
};
},
getColumnSortIcon(columnName) {
return this.columnIconMeta[columnName].sortIcon;
},
getColumnSortTooltip(columnName) {
return this.columnIconMeta[columnName].iconTooltip;
},
onColumnClick(columnName) {
this.$emit('onColumnClick', columnName);
this.columnIconMeta[columnName] = this.getColumnIconMeta(columnName, this.sortOrders);
},
},
};
</script>
<template>
<thead>
<tr>
<th
v-for="(column, index) in columns"
:key="index"
class="header"
:title="getColumnSortTooltip(column.name)"
@click="onColumnClick(column.name)"
>
{{ column.text }}
<icon
:size="12"
:name="getColumnSortIcon(column.name)"
/>
</th>
</tr>
</thead>
</template>
import { __ } from '~/locale';
const COLUMNS = [
{ name: 'fullname', text: __('Name') },
{ name: 'push', text: __('Pushed') },
{ name: 'issuesCreated', text: __('Opened issues') },
{ name: 'issuesClosed', text: __('Closed issues') },
{ name: 'mergeRequestsCreated', text: __('Opened MR') },
{ name: 'mergeRequestsMerged', text: __('Accepted MR') },
{ name: 'totalEvents', text: __('Total Contributions') },
];
export default COLUMNS;
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import GroupMemberStore from './store/group_member_store';
import GroupMemberService from './service/group_member_service';
import GroupMemberContributionsApp from './components/app.vue';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-group-member-contributions');
if (!el) {
return false;
}
return new Vue({
el,
components: {
GroupMemberContributionsApp,
},
data() {
const { memberContributionsPath } = el.dataset;
const store = new GroupMemberStore();
const service = new GroupMemberService(memberContributionsPath);
return {
store,
service,
};
},
render(createElement) {
return createElement('group-member-contributions-app', {
props: {
store: this.store,
service: this.service,
},
});
},
});
};
import axios from '~/lib/utils/axios_utils';
export default class GroupMemberService {
constructor(memberContributionsPath) {
this.memberContributionsPath = memberContributionsPath;
}
getContributedMembers() {
return axios.get(this.memberContributionsPath);
}
}
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default class GroupMemberStore {
constructor() {
this.state = {};
this.state.members = [];
this.state.columns = [];
this.state.sortOrders = {};
this.state.currentSortedColumn = '';
}
get members() {
return this.state.members;
}
get sortOrders() {
return this.state.sortOrders;
}
setColumns(columns) {
this.state.columns = columns;
this.state.sortOrders = this.state.columns.reduce(
(acc, column) => ({ ...acc, [column.name]: 1 }),
{},
);
}
setMembers(rawMembers) {
this.state.members = rawMembers.map(rawMember => GroupMemberStore.formatMember(rawMember));
}
sortMembers(sortByColumn) {
if (sortByColumn) {
this.state.currentSortedColumn = sortByColumn;
this.state.sortOrders[sortByColumn] = this.state.sortOrders[sortByColumn] * -1;
const currentColumnOrder = this.state.sortOrders[sortByColumn] || 1;
const members = this.state.members.slice().sort((a, b) => {
let delta = -1;
const columnOrderA = a[sortByColumn];
const columnOrderB = b[sortByColumn];
if (columnOrderA === columnOrderB) {
delta = 0;
} else if (columnOrderA > columnOrderB) {
delta = 1;
}
return delta * currentColumnOrder;
});
this.state.members = members;
}
}
static formatMember(rawMember) {
return convertObjectPropsToCamelCase(rawMember);
}
}
import $ from 'jquery';
import Chart from 'chart.js';
import initGroupMemberContributions from 'ee/group_member_contributions';
document.addEventListener('DOMContentLoaded', () => {
const dataEl = document.getElementById('js-analytics-data');
......@@ -32,6 +32,6 @@ document.addEventListener('DOMContentLoaded', () => {
);
});
$('#event-stats').tablesorter();
initGroupMemberContributions();
}
});
......@@ -20,3 +20,18 @@
color: $black;
}
}
.gl-sortable {
.header {
user-select: none;
&:hover {
cursor: pointer;
background-color: $theme-gray-100;
}
&:focus {
outline: 1px solid $focus-border-color;
}
}
}
class Groups::AnalyticsController < Groups::ApplicationController
before_action :group
before_action :check_contribution_analytics_available!
before_action :load_events
layout 'group'
def show
@users = @group.users.select(:id, :name, :username).reorder(:id)
@start_date = params[:start_date] || Date.today - 1.week
@events = Event.contributions
.where("created_at > ?", @start_date)
.where(project_id: @group.projects)
respond_to do |format|
format.html do
@stats = {}
@stats[:push] = count_by_user(event_totals[:push])
@stats[:merge_requests_created] = count_by_user(event_totals[:merge_requests_created])
@stats[:issues_closed] = count_by_user(event_totals[:issues_closed])
end
@stats[:total_events] = count_by_user(@events.totals_by_author)
@stats[:push] = count_by_user(@events.code_push.totals_by_author)
@stats[:merge_requests_created] = count_by_user(@events.merge_requests.created.totals_by_author)
@stats[:merge_requests_merged] = count_by_user(@events.merge_requests.merged.totals_by_author)
@stats[:issues_created] = count_by_user(@events.issues.created.totals_by_author)
@stats[:issues_closed] = count_by_user(@events.issues.closed.totals_by_author)
format.json do
render json: GroupAnalyticsSerializer
.new(events: event_totals)
.represent(users), status: 200
end
end
end
private
def count_by_user(data)
user_ids.map { |id| data.fetch(id, 0) }
users.map { |user| data.fetch(user.id, 0) }
end
def users
@users ||= @group.users.select(:id, :name, :username).reorder(:id)
end
def load_events
@start_date = params[:start_date] || Date.today - 1.week
@events = Event.contributions
.where("created_at > ?", @start_date)
.where(project_id: @group.projects)
end
def user_ids
@user_ids ||= @users.map(&:id)
def event_totals
@event_totals ||= {
push: @events.code_push.totals_by_author,
issues_created: @events.issues.created.totals_by_author,
issues_closed: @events.issues.closed.totals_by_author,
merge_requests_created: @events.merge_requests.created.totals_by_author,
merge_requests_merged: @events.merge_requests.merged.totals_by_author,
total_events: @events.totals_by_author
}
end
def check_contribution_analytics_available!
......
class GroupAnalyticsSerializer < BaseSerializer
entity UserAnalyticsEntity
end
class UserAnalyticsEntity < Grape::Entity
include RequestAwareEntity
EVENT_TYPES = [:push, :issues_created, :issues_closed, :merge_requests_created,
:merge_requests_merged, :total_events].freeze
expose :username
expose :name, as: :fullname
expose :user_web_url do |user|
user_path(user)
end
EVENT_TYPES.each do |event_type|
expose event_type do |user|
request.events[event_type].fetch(user.id, 0)
end
end
end
- page_title "Contribution Analytics"
- header_title group_title(@group, "Contribution Analytics", group_analytics_path(@group))
- if @group.feature_available?(:contribution_analytics)
.sub-header-block
......@@ -76,48 +75,7 @@
%p.light Issues closed per group member
%canvas#issues_closed{ height: 250 }
.gray-content-block
.oneline
Contributions per group member
.table-holder
%table.table.sortable-table#event-stats
%thead
%tr
%th.sortable
Name
= icon('sort')
%th.sortable
Pushed
= icon('sort')
%th.sortable
Opened issues
= icon('sort')
%th.sortable
Closed issues
= icon('sort')
%th.sortable
Opened MR
= icon('sort')
%th.sortable
Accepted MR
= icon('sort')
%th.sortable
Total Contributions
= icon('sort')
%tbody
- @users.each_with_index do |user, index|
%tr
%td
%strong
= link_to user.name, user
%td= @stats[:push][index]
%td= @stats[:issues_created][index]
%td= @stats[:issues_closed][index]
%td= @stats[:merge_requests_created][index]
%td= @stats[:merge_requests_merged][index]
%td= @stats[:total_events][index]
#js-group-member-contributions{ data: { member_contributions_path: group_analytics_path(@group, { start_date: @start_date, format: :json }) } }
-# haml-lint:disable InlineJavaScript
%script#js-analytics-data{ type: "application/json" }
- data = {}
......
---
title: Port Group member contribution analytics table to Vue
merge_request: 5269
author:
type: performance
......@@ -72,13 +72,41 @@ describe Groups::AnalyticsController do
stats = assigns[:stats]
# NOTE: The array ordering matters! The view references them all by index
expect(stats[:total_events]).to eq([2, 2, 2])
expect(stats[:merge_requests_merged]).to eq([0, 0, 0])
expect(stats[:merge_requests_created]).to eq([0, 1, 1])
expect(stats[:issues_closed]).to eq([1, 1, 0])
expect(stats[:push]).to eq([1, 0, 1])
end
it "returns member contributions JSON when format is JSON" do
get :show, group_id: group.path, format: :json
expect(json_response.length).to eq(3)
first_user = json_response.at(0)
expect(first_user["username"]).to eq(user.username)
expect(first_user["user_web_url"]).to eq("/#{user.username}")
expect(first_user["fullname"]).to eq(user.name)
expect(first_user["push"]).to eq(1)
expect(first_user["issues_created"]).to eq(0)
expect(first_user["issues_closed"]).to eq(1)
expect(first_user["merge_requests_created"]).to eq(0)
expect(first_user["merge_requests_merged"]).to eq(0)
expect(first_user["total_events"]).to eq(2)
end
it 'does not cause N+1 queries when the format is JSON' do
control_count = ActiveRecord::QueryRecorder.new do
get :show, group_id: group.path, format: :json
end
controller.instance_variable_set(:@group, nil)
user4 = create(:user)
group.add_user(user4, GroupMember::DEVELOPER)
expect { get :show, group_id: group.path, format: :json }
.not_to exceed_query_limit(control_count)
end
describe 'with views' do
render_views
......
require 'spec_helper'
describe UserAnalyticsEntity do
let(:user) { build_stubbed(:user) }
let(:events) do
{
push: {},
issues_created: {},
issues_closed: {},
merge_requests_created: {},
merge_requests_merged: {},
total_events: {}
}
end
let(:request) { double('request') }
subject(:json) { described_class.new(user, request: request).as_json }
before do
allow(request).to receive(:events).and_return(events)
end
it 'has all the user attributes' do
is_expected.to include(:username, :fullname, :user_web_url)
end
[:push, :issues_created, :issues_closed, :merge_requests_created,
:merge_requests_merged, :total_events].each do |event_type|
it "fetches #{event_type} events for the user from the request" do
events[event_type] = { user.id => 42 }
expect(json[event_type]).to eq(42)
end
end
it 'sets 0 as the total when there were no events for a type' do
expect(json[:total_events]).to eq(0)
end
end
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import AppComponent from 'ee/group_member_contributions/components/app.vue';
import GroupMemberStore from 'ee/group_member_contributions/store/group_member_store';
import GroupMemberService from 'ee/group_member_contributions/service/group_member_service';
import { contributionsPath, rawMembers } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(AppComponent);
const store = new GroupMemberStore();
const service = new GroupMemberService(contributionsPath);
return mountComponent(Component, {
store,
service,
});
};
describe('AppComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('fetchContributedMembers', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
document.body.innerHTML += '<div class="flash-container"></div>';
});
afterEach(() => {
mock.restore();
document.querySelector('.flash-container').remove();
});
it('calls service.getContributedMembers and sets response to the store on success', done => {
mock.onGet(vm.service.memberContributionsPath).reply(200, rawMembers);
spyOn(vm.store, 'setColumns');
spyOn(vm.store, 'setMembers');
vm.fetchContributedMembers();
expect(vm.isLoading).toBe(true);
setTimeout(() => {
expect(vm.isLoading).toBe(false);
expect(vm.store.setColumns).toHaveBeenCalledWith(jasmine.any(Object));
expect(vm.store.setMembers).toHaveBeenCalledWith(rawMembers);
done();
}, 0);
});
it('calls service.getContributedMembers and sets `isLoading` to false and shows flash message if request failed', done => {
mock.onGet(vm.service.memberContributionsPath).reply(500, {});
vm.fetchContributedMembers();
expect(vm.isLoading).toBe(true);
setTimeout(() => {
expect(vm.isLoading).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
'Something went wrong while fetching group member contributions',
);
done();
}, 0);
});
});
describe('handleColumnClick', () => {
it('calls store.sortMembers with columnName param', () => {
spyOn(vm.store, 'sortMembers');
const columnName = 'fullname';
vm.handleColumnClick(columnName);
expect(vm.store.sortMembers).toHaveBeenCalledWith(columnName);
});
});
});
describe('template', () => {
it('renders component container element with class `group-member-contributions-container`', () => {
expect(vm.$el.classList.contains('group-member-contributions-container')).toBe(true);
});
it('renders header title element within component containe', () => {
expect(vm.$el.querySelector('h3').innerText.trim()).toBe('Contributions per group member');
});
it('shows loading icon when isLoading prop is true', done => {
vm.isLoading = true;
vm
.$nextTick()
.then(() => {
const loadingEl = vm.$el.querySelector('.loading-animation');
expect(loadingEl).not.toBeNull();
expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe(
'Loading contribution stats for group members',
);
})
.then(done)
.catch(done.fail);
});
it('renders table container element', done => {
vm.isLoading = false;
vm
.$nextTick()
.then(() => {
expect(vm.$el.querySelector('table.table.gl-sortable')).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
});
});
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import TableBodyComponent from 'ee/group_member_contributions/components/table_body.vue';
import GroupMemberStore from 'ee/group_member_contributions/store/group_member_store';
import { rawMembers } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(TableBodyComponent);
const store = new GroupMemberStore();
store.setMembers(rawMembers);
const rows = store.members;
return mountComponent(Component, { rows });
};
describe('TableBodyComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders row item element', () => {
const rowEl = vm.$el.querySelector('tr');
expect(rowEl).not.toBeNull();
expect(rowEl.querySelectorAll('td').length).toBe(7);
});
it('renders username row cell element', () => {
const cellEl = vm.$el.querySelector('td strong');
expect(cellEl).not.toBeNull();
expect(cellEl.querySelector('a').getAttribute('href')).toBe(rawMembers[0].user_web_url);
});
});
});
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import TableHeaderComponent from 'ee/group_member_contributions/components/table_header.vue';
import defaultColumns from 'ee/group_member_contributions/constants';
import { mockSortOrders } from '../mock_data';
const createComponent = (columns = defaultColumns, sortOrders = mockSortOrders) => {
const Component = Vue.extend(TableHeaderComponent);
return mountComponent(Component, { columns, sortOrders });
};
describe('TableHeaderComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns data with columnIconMeta prop initialized', () => {
defaultColumns.forEach(column => {
expect(vm.columnIconMeta[column.name].sortIcon).toBe('angle-up');
expect(vm.columnIconMeta[column.name].iconTooltip).toBe('Ascending');
});
});
});
describe('methods', () => {
const columnName = 'fullname';
describe('getColumnIconMeta', () => {
it('returns `angle-up` and `Ascending` for sortIcon and iconTooltip respectively when provided columnName in sortOrders has value greater than 0', () => {
const iconMeta = vm.getColumnIconMeta(columnName, { fullname: 1 });
expect(iconMeta.sortIcon).toBe('angle-up');
expect(iconMeta.iconTooltip).toBe('Ascending');
});
it('returns `angle-down` and `Descending` for sortIcon and iconTooltip respectively when provided columnName in sortOrders has value less than 0', () => {
const iconMeta = vm.getColumnIconMeta(columnName, { fullname: -1 });
expect(iconMeta.sortIcon).toBe('angle-down');
expect(iconMeta.iconTooltip).toBe('Descending');
});
});
describe('getColumnSortIcon', () => {
it('returns value of sortIcon for provided columnName', () => {
expect(vm.getColumnSortIcon(columnName)).toBe('angle-up');
});
});
describe('getColumnSortTooltip', () => {
it('returns value of iconTooltip for provided columnName', () => {
expect(vm.getColumnSortTooltip(columnName)).toBe('Ascending');
});
});
describe('onColumnClick', () => {
it('emits `onColumnClick` event with columnName param on component instance', () => {
spyOn(vm, '$emit');
vm.onColumnClick(columnName);
expect(vm.$emit).toHaveBeenCalledWith('onColumnClick', columnName);
});
it('updates columnIconMeta prop for provided columnName', () => {
spyOn(vm, 'getColumnIconMeta');
vm.onColumnClick(columnName);
expect(vm.getColumnIconMeta).toHaveBeenCalledWith(columnName, vm.sortOrders);
});
});
});
describe('template', () => {
it('renders table column header with sort order icon', () => {
const headerItemEl = vm.$el.querySelector('tr th');
expect(headerItemEl).not.toBeNull();
expect(headerItemEl.innerText.trim()).toBe('Name');
expect(headerItemEl.querySelector('svg use').getAttribute('xlink:href')).toContain(
'angle-up',
);
});
});
});
export const contributionsPath = '/foo/analytics.json';
export const rawMembers = [
{
username: 'root',
fullname: 'Administrator',
user_web_url: '/root',
push: 0,
issues_created: 9,
issues_closed: 4,
merge_requests_created: 2,
merge_requests_merged: 0,
total_events: 51,
},
{
username: 'monserrate.gleichner',
fullname: 'Terrell Graham',
user_web_url: '/monserrate.gleichner',
push: 0,
issues_created: 7,
issues_closed: 1,
merge_requests_created: 5,
merge_requests_merged: 0,
total_events: 49,
},
{
username: 'melynda',
fullname: 'Bryce Turcotte',
user_web_url: '/melynda',
push: 0,
issues_created: 6,
issues_closed: 1,
merge_requests_created: 1,
merge_requests_merged: 0,
total_events: 44,
},
];
export const mockSortOrders = {
fullname: 1,
issuesClosed: 1,
issuesCreated: 1,
mergeRequestsCreated: 1,
mergeRequestsMerged: 1,
push: 1,
totalEvents: 1,
};
import axios from '~/lib/utils/axios_utils';
import GroupMemberService from 'ee/group_member_contributions/service/group_member_service';
import { contributionsPath } from '../mock_data';
describe('GroupMemberService', () => {
let service;
beforeEach(() => {
service = new GroupMemberService(contributionsPath);
});
describe('constructor', () => {
it('initializes default properties', () => {
expect(service.memberContributionsPath).toBe(contributionsPath);
});
});
describe('getContributedMembers', () => {
it('returns axios instance for memberContributionsPath', () => {
spyOn(axios, 'get').and.stub();
service.getContributedMembers();
expect(axios.get).toHaveBeenCalledWith(service.memberContributionsPath);
});
});
});
import GroupMemberStore from 'ee/group_member_contributions/store/group_member_store';
import defaultColumns from 'ee/group_member_contributions/constants';
import { rawMembers } from '../mock_data';
describe('GroupMemberStore', () => {
let store;
beforeEach(() => {
store = new GroupMemberStore();
});
describe('setColumns', () => {
beforeEach(() => {
store.setColumns(defaultColumns);
});
it('sets columns to store state', () => {
expect(store.state.columns).toBe(defaultColumns);
});
it('initializes sortOrders on store state', () => {
Object.keys(store.state.sortOrders).forEach(column => {
expect(store.state.sortOrders[column]).toBe(1);
});
});
});
describe('setMembers', () => {
it('sets members to store state', () => {
store.setMembers(rawMembers);
expect(store.state.members.length).toBe(rawMembers.length);
});
});
describe('sortMembers', () => {
it('sorts members list based on provided column name', () => {
store.setColumns(defaultColumns);
store.setMembers(rawMembers);
let firstMember = store.state.members[0];
expect(firstMember.fullname).toBe('Administrator');
store.sortMembers('fullname');
firstMember = store.state.members[0];
expect(firstMember.fullname).toBe('Terrell Graham');
});
});
});
This diff is collapsed.
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