Commit 8614b436 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ee-49801-add-new-overview-tab-on-user-profile-page' into 'master'

(EE Port) Adds overview tab to user profile page

See merge request gitlab-org/gitlab-ee!7783
parents ae3fe99b fa0ebee2
......@@ -43,7 +43,15 @@ const initColorKey = () =>
.domain([0, 3]);
export default class ActivityCalendar {
constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0, firstDayOfWeek = 0) {
constructor(
container,
activitiesContainer,
timestamps,
calendarActivitiesPath,
utcOffset = 0,
firstDayOfWeek = 0,
monthsAgo = 12,
) {
this.calendarActivitiesPath = calendarActivitiesPath;
this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
......@@ -66,6 +74,8 @@ export default class ActivityCalendar {
];
this.months = [];
this.firstDayOfWeek = firstDayOfWeek;
this.activitiesContainer = activitiesContainer;
this.container = container;
// Loop through the timestamps to create a group of objects
// The group of objects will be grouped based on the day of the week they are
......@@ -75,13 +85,13 @@ export default class ActivityCalendar {
const today = getSystemDate(utcOffset);
today.setHours(0, 0, 0, 0, 0);
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
const timeAgo = new Date(today);
timeAgo.setMonth(today.getMonth() - monthsAgo);
const days = getDayDifference(oneYearAgo, today);
const days = getDayDifference(timeAgo, today);
for (let i = 0; i <= days; i += 1) {
const date = new Date(oneYearAgo);
const date = new Date(timeAgo);
date.setDate(date.getDate() + i);
const day = date.getDay();
......@@ -280,7 +290,7 @@ export default class ActivityCalendar {
this.currentSelectedDate.getDate(),
].join('-');
$('.user-calendar-activities').html(LOADING_HTML);
$(this.activitiesContainer).html(LOADING_HTML);
axios
.get(this.calendarActivitiesPath, {
......@@ -289,11 +299,11 @@ export default class ActivityCalendar {
},
responseType: 'text',
})
.then(({ data }) => $('.user-calendar-activities').html(data))
.then(({ data }) => $(this.activitiesContainer).html(data))
.catch(() => flash(__('An error occurred while retrieving calendar activity')));
} else {
this.currentSelectedDate = '';
$('.user-calendar-activities').html('');
$(this.activitiesContainer).html('');
}
}
}
import axios from '~/lib/utils/axios_utils';
export default class UserOverviewBlock {
constructor(options = {}) {
this.container = options.container;
this.url = options.url;
this.limit = options.limit || 20;
this.loadData();
}
loadData() {
const loadingEl = document.querySelector(`${this.container} .loading`);
loadingEl.classList.remove('hide');
axios
.get(this.url, {
params: {
limit: this.limit,
},
})
.then(({ data }) => this.render(data))
.catch(() => loadingEl.classList.add('hide'));
}
render(data) {
const { html, count } = data;
const contentList = document.querySelector(`${this.container} .overview-content-list`);
contentList.innerHTML += html;
const loadingEl = document.querySelector(`${this.container} .loading`);
if (count && count > 0) {
document.querySelector(`${this.container} .js-view-all`).classList.remove('hide');
} else {
document.querySelector(`${this.container} .nothing-here-block`).classList.add('text-left', 'p-0');
}
loadingEl.classList.add('hide');
}
}
......@@ -2,9 +2,10 @@ import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import Activities from '~/activities';
import { localTimeAgo } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import flash from '~/flash';
import ActivityCalendar from './activity_calendar';
import UserOverviewBlock from './user_overview_block';
/**
* UserTabs
......@@ -61,19 +62,28 @@ import ActivityCalendar from './activity_calendar';
* </div>
*/
const CALENDAR_TEMPLATE = `
<div class="clearfix calendar">
<div class="js-contrib-calendar"></div>
<div class="calendar-hint">
Summary of issues, merge requests, push events, and comments
const CALENDAR_TEMPLATES = {
activity: `
<div class="clearfix calendar">
<div class="js-contrib-calendar"></div>
<div class="calendar-hint bottom-right"></div>
</div>
</div>
`;
`,
overview: `
<div class="clearfix calendar">
<div class="calendar-hint"></div>
<div class="js-contrib-calendar prepend-top-20"></div>
</div>
`,
};
const CALENDAR_PERIOD_6_MONTHS = 6;
const CALENDAR_PERIOD_12_MONTHS = 12;
export default class UserTabs {
constructor({ defaultAction, action, parentEl }) {
this.loaded = {};
this.defaultAction = defaultAction || 'activity';
this.defaultAction = defaultAction || 'overview';
this.action = action || this.defaultAction;
this.$parentEl = $(parentEl) || $(document);
this.windowLocation = window.location;
......@@ -124,6 +134,8 @@ export default class UserTabs {
}
if (action === 'activity') {
this.loadActivities();
} else if (action === 'overview') {
this.loadOverviewTab();
}
const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
......@@ -154,7 +166,40 @@ export default class UserTabs {
if (this.loaded.activity) {
return;
}
const $calendarWrap = this.$parentEl.find('.user-calendar');
this.loadActivityCalendar('activity');
// eslint-disable-next-line no-new
new Activities();
this.loaded.activity = true;
}
loadOverviewTab() {
if (this.loaded.overview) {
return;
}
this.loadActivityCalendar('overview');
UserTabs.renderMostRecentBlocks('#js-overview .activities-block', 5);
UserTabs.renderMostRecentBlocks('#js-overview .projects-block', 10);
this.loaded.overview = true;
}
static renderMostRecentBlocks(container, limit) {
// eslint-disable-next-line no-new
new UserOverviewBlock({
container,
url: $(`${container} .overview-content-list`).data('href'),
limit,
});
}
loadActivityCalendar(action) {
const monthsAgo = action === 'overview' ? CALENDAR_PERIOD_6_MONTHS : CALENDAR_PERIOD_12_MONTHS;
const $calendarWrap = this.$parentEl.find('.tab-pane.active .user-calendar');
const calendarPath = $calendarWrap.data('calendarPath');
const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath');
const utcOffset = $calendarWrap.data('utcOffset');
......@@ -166,17 +211,22 @@ export default class UserTabs {
axios
.get(calendarPath)
.then(({ data }) => {
$calendarWrap.html(CALENDAR_TEMPLATE);
$calendarWrap.find('.calendar-hint').append(`(Timezone: ${utcFormatted})`);
$calendarWrap.html(CALENDAR_TEMPLATES[action]);
let calendarHint = '';
if (action === 'activity') {
calendarHint = sprintf(__('Summary of issues, merge requests, push events, and comments (Timezone: %{utcFormatted})'), { utcFormatted });
} else if (action === 'overview') {
calendarHint = __('Issues, merge requests, pushes and comments.');
}
$calendarWrap.find('.calendar-hint').text(calendarHint);
// eslint-disable-next-line no-new
new ActivityCalendar('.js-contrib-calendar', data, calendarActivitiesPath, utcOffset);
new ActivityCalendar('.tab-pane.active .js-contrib-calendar', '.tab-pane.active .user-calendar-activities', data, calendarActivitiesPath, utcOffset, 0, monthsAgo);
})
.catch(() => flash(__('There was an error loading users activity calendar.')));
// eslint-disable-next-line no-new
new Activities();
this.loaded.activity = true;
}
toggleLoading(status) {
......
.calender-block {
.calendar-block {
padding-left: 0;
padding-right: 0;
border-top: 0;
direction: rtl;
@media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
overflow-x: auto;
......@@ -42,10 +41,13 @@
}
.calendar-hint {
margin-top: -23px;
float: right;
font-size: 12px;
direction: ltr;
&.bottom-right {
direction: ltr;
margin-top: -23px;
float: right;
}
}
.pika-single.gitlab-theme {
......
......@@ -30,11 +30,17 @@ class UsersController < ApplicationController
format.json do
load_events
pager_json("events/_events", @events.count)
pager_json("events/_events", @events.count, events: @events)
end
end
end
def activity
respond_to do |format|
format.html { render 'show' }
end
end
def groups
load_groups
......@@ -54,9 +60,7 @@ class UsersController < ApplicationController
respond_to do |format|
format.html { render 'show' }
format.json do
render json: {
html: view_to_html_string("shared/projects/_list", projects: @projects)
}
pager_json("shared/projects/_list", @projects.count, projects: @projects)
end
end
end
......@@ -126,6 +130,7 @@ class UsersController < ApplicationController
@projects =
PersonalProjectsFinder.new(user).execute(current_user)
.page(params[:page])
.per(params[:limit])
prepare_projects_for_rendering(@projects)
end
......
......@@ -31,7 +31,7 @@ class UserRecentEventsFinder
recent_events(params[:offset] || 0)
.joins(:project)
.with_associations
.limit_recent(LIMIT, params[:offset])
.limit_recent(params[:limit].presence || LIMIT, params[:offset])
end
# rubocop: enable CodeReuse/ActiveRecord
......
......@@ -76,7 +76,7 @@ module UsersHelper
tabs = []
if can?(current_user, :read_user_profile, @user)
tabs += [:activity, :groups, :contributed, :projects, :snippets]
tabs += [:overview, :activity, :groups, :contributed, :projects, :snippets]
end
tabs
......
.row
.col-md-12.col-lg-6
.calendar-block
.content-block.hide-bottom-border
%h4
= s_('UserProfile|Activity')
.user-calendar.d-none.d-sm-block.text-left{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
%h4.center.light
%i.fa.fa-spinner.fa-spin
.user-calendar-activities.d-none.d-sm-block
- if can?(current_user, :read_cross_project)
.activities-block
.content-block
%h5.prepend-top-10
= s_('UserProfile|Recent contributions')
.overview-content-list{ data: { href: user_path } }
.center.light.loading
%i.fa.fa-spinner.fa-spin
.prepend-top-10
= link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
.col-md-12.col-lg-6
.projects-block
.content-block
%h4
= s_('UserProfile|Personal projects')
.overview-content-list{ data: { href: user_projects_path } }
.center.light.loading
%i.fa.fa-spinner.fa-spin
.prepend-top-10
= link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
......@@ -12,22 +12,22 @@
.cover-block.user-cover-block.top-area
.cover-controls
- if @user == current_user
= link_to profile_path, class: 'btn btn-default has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do
= link_to profile_path, class: 'btn btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do
= icon('pencil')
- elsif current_user
- if @user.abuse_report
%button.btn.btn-danger{ title: 'Already reported for abuse',
%button.btn.btn-danger{ title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }
= icon('exclamation-circle')
- else
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn',
title: 'Report abuse', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('exclamation-circle')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: 'btn btn-default has-tooltip', title: 'Subscribe', 'aria-label': 'Subscribe' do
= link_to user_path(@user, rss_url_options), class: 'btn btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do
= icon('rss')
- if current_user && current_user.admin?
= link_to [:admin, @user], class: 'btn btn-default', title: 'View user in admin area',
= link_to [:admin, @user], class: 'btn btn-default', title: s_('UserProfile|View user in admin area'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('users')
......@@ -51,7 +51,7 @@
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
%span.middle-dot-divider
Member since #{@user.created_at.to_date.to_s(:long)}
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
.cover-desc
- unless @user.public_email.blank?
......@@ -91,32 +91,40 @@
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs
- if profile_tab?(:overview)
%li.js-overview-tab
= link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do
= s_('UserProfile|Overview')
- if profile_tab?(:activity)
%li.js-activity-tab
= link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
Activity
= link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
= s_('UserProfile|Activity')
- if profile_tab?(:groups)
%li.js-groups-tab
= link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
Groups
= s_('UserProfile|Groups')
- if profile_tab?(:contributed)
%li.js-contributed-tab
= link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
Contributed projects
= s_('UserProfile|Contributed projects')
- if profile_tab?(:projects)
%li.js-projects-tab
= link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
Personal projects
= s_('UserProfile|Personal projects')
- if profile_tab?(:snippets)
%li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
Snippets
= s_('UserProfile|Snippets')
%div{ class: container_class }
.tab-content
- if profile_tab?(:overview)
#js-overview.tab-pane
= render "users/overview"
- if profile_tab?(:activity)
#activity.tab-pane
.row-content-block.calender-block.white.second-block.d-none.d-sm-block
.row-content-block.calendar-block.white.second-block.d-none.d-sm-block
.user-calendar{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
%h4.center.light
%i.fa.fa-spinner.fa-spin
......@@ -124,7 +132,7 @@
- if can?(current_user, :read_cross_project)
%h4.prepend-top-20
Most Recent Activity
= s_('UserProfile|Most Recent Activity')
.content_list{ data: { href: user_path } }
= spinner
......@@ -155,4 +163,4 @@
.col-12.text-center
.text-content
%h4
This user has a private profile
= s_('UserProfile|This user has a private profile')
---
title: Adds new 'Overview' tab on user profile page
merge_request: 21663
author:
type: other
......@@ -62,6 +62,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :snippets
get :exists
get :pipelines_quota
get :activity
get '/', to: redirect('%{username}'), as: nil
## EE-specific
......
......@@ -4204,6 +4204,9 @@ msgstr ""
msgid "Issues closed"
msgstr ""
msgid "Issues, merge requests, pushes and comments."
msgstr ""
msgid "Jan"
msgstr ""
......@@ -4649,6 +4652,9 @@ msgstr ""
msgid "Median"
msgstr ""
msgid "Member since %{date}"
msgstr ""
msgid "Members"
msgstr ""
......@@ -7206,6 +7212,9 @@ msgstr ""
msgid "Subscribe at project level"
msgstr ""
msgid "Summary of issues, merge requests, push events, and comments (Timezone: %{utcFormatted})"
msgstr ""
msgid "Switch branch/tag"
msgstr ""
......@@ -8160,6 +8169,51 @@ msgstr ""
msgid "User map"
msgstr ""
msgid "UserProfile|Activity"
msgstr ""
msgid "UserProfile|Already reported for abuse"
msgstr ""
msgid "UserProfile|Contributed projects"
msgstr ""
msgid "UserProfile|Edit profile"
msgstr ""
msgid "UserProfile|Groups"
msgstr ""
msgid "UserProfile|Most Recent Activity"
msgstr ""
msgid "UserProfile|Overview"
msgstr ""
msgid "UserProfile|Personal projects"
msgstr ""
msgid "UserProfile|Recent contributions"
msgstr ""
msgid "UserProfile|Report abuse"
msgstr ""
msgid "UserProfile|Snippets"
msgstr ""
msgid "UserProfile|Subscribe"
msgstr ""
msgid "UserProfile|This user has a private profile"
msgstr ""
msgid "UserProfile|View all"
msgstr ""
msgid "UserProfile|View user in admin area"
msgstr ""
msgid "Users"
msgstr ""
......
......@@ -64,7 +64,7 @@ describe 'Contributions Calendar', :js do
end
def selected_day_activities(visible: true)
find('.user-calendar-activities', visible: visible).text
find('.tab-pane#activity .user-calendar-activities', visible: visible).text
end
before do
......@@ -74,15 +74,16 @@ describe 'Contributions Calendar', :js do
describe 'calendar day selection' do
before do
visit user.username
page.find('.js-activity-tab a').click
wait_for_requests
end
it 'displays calendar' do
expect(page).to have_css('.js-contrib-calendar')
expect(find('.tab-pane#activity')).to have_css('.js-contrib-calendar')
end
describe 'select calendar day' do
let(:cells) { page.all('.user-contrib-cell') }
let(:cells) { page.all('.tab-pane#activity .user-contrib-cell') }
before do
cells[0].click
......@@ -108,6 +109,7 @@ describe 'Contributions Calendar', :js do
describe 'deselect calendar day' do
before do
cells[0].click
page.find('.js-activity-tab a').click
wait_for_requests
end
......@@ -122,6 +124,7 @@ describe 'Contributions Calendar', :js do
shared_context 'visit user page' do
before do
visit user.username
page.find('.js-activity-tab a').click
wait_for_requests
end
end
......@@ -130,12 +133,12 @@ describe 'Contributions Calendar', :js do
include_context 'visit user page'
it 'displays calendar activity square color for 1 contribution' do
expect(page).to have_selector(get_cell_color_selector(contribution_count), count: 1)
expect(find('.tab-pane#activity')).to have_selector(get_cell_color_selector(contribution_count), count: 1)
end
it 'displays calendar activity square on the correct date' do
today = Date.today.strftime(date_format)
expect(page).to have_selector(get_cell_date_selector(contribution_count, today), count: 1)
expect(find('.tab-pane#activity')).to have_selector(get_cell_date_selector(contribution_count, today), count: 1)
end
end
......@@ -150,7 +153,7 @@ describe 'Contributions Calendar', :js do
include_context 'visit user page'
it 'displays calendar activity log' do
expect(find('.content_list .event-note')).to have_content issue_title
expect(find('.tab-pane#activity .content_list .event-note')).to have_content issue_title
end
end
end
......@@ -182,17 +185,17 @@ describe 'Contributions Calendar', :js do
include_context 'visit user page'
it 'displays calendar activity squares for both days' do
expect(page).to have_selector(get_cell_color_selector(1), count: 2)
expect(find('.tab-pane#activity')).to have_selector(get_cell_color_selector(1), count: 2)
end
it 'displays calendar activity square for yesterday' do
yesterday = Date.yesterday.strftime(date_format)
expect(page).to have_selector(get_cell_date_selector(1, yesterday), count: 1)
expect(find('.tab-pane#activity')).to have_selector(get_cell_date_selector(1, yesterday), count: 1)
end
it 'displays calendar activity square for today' do
today = Date.today.strftime(date_format)
expect(page).to have_selector(get_cell_date_selector(1, today), count: 1)
expect(find('.tab-pane#activity')).to have_selector(get_cell_date_selector(1, today), count: 1)
end
end
end
......
......@@ -14,7 +14,7 @@ describe 'Tooltips on .timeago dates', :js do
updated_at: created_date, created_at: created_date)
sign_in user
visit user_path(user)
visit user_activity_path(user)
wait_for_requests()
page.find('.js-timeago').hover
......
require 'spec_helper'
describe 'Overview tab on a user profile', :js do
let(:user) { create(:user) }
let(:contributed_project) { create(:project, :public, :repository) }
def push_code_contribution
event = create(:push_event, project: contributed_project, author: user)
create(:push_event_payload,
event: event,
commit_from: '11f9ac0a48b62cef25eedede4c1819964f08d5ce',
commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
commit_count: 3,
ref: 'master')
end
before do
sign_in user
end
describe 'activities section' do
shared_context 'visit overview tab' do
before do
visit user.username
page.find('.js-overview-tab a').click
wait_for_requests
end
end
describe 'user has no activities' do
include_context 'visit overview tab'
it 'does not show any entries in the list of activities' do
page.within('.activities-block') do
expect(page).not_to have_selector('.event-item')
end
end
it 'does not show a link to the activity list' do
expect(find('#js-overview .activities-block')).to have_selector('.js-view-all', visible: false)
end
end
describe 'user has 3 activities' do
before do
3.times { push_code_contribution }
end
include_context 'visit overview tab'
it 'display 3 entries in the list of activities' do
expect(find('#js-overview')).to have_selector('.event-item', count: 3)
end
end
describe 'user has 10 activities' do
before do
10.times { push_code_contribution }
end
include_context 'visit overview tab'
it 'displays 5 entries in the list of activities' do
expect(find('#js-overview')).to have_selector('.event-item', count: 5)
end
it 'shows a link to the activity list' do
expect(find('#js-overview .activities-block')).to have_selector('.js-view-all', visible: true)
end
it 'links to the activity tab' do
page.within('.activities-block') do
find('.js-view-all').click
wait_for_requests
expect(URI.parse(current_url).path).to eq("/users/#{user.username}/activity")
end
end
end
end
describe 'projects section' do
shared_context 'visit overview tab' do
before do
visit user.username
page.find('.js-overview-tab a').click
wait_for_requests
end
end
describe 'user has no personal projects' do
include_context 'visit overview tab'
it 'it shows an empty project list with an info message' do
page.within('.projects-block') do
expect(page).to have_content('No projects found')
expect(page).not_to have_selector('.project-row')
end
end
it 'does not show a link to the project list' do
expect(find('#js-overview .projects-block')).to have_selector('.js-view-all', visible: false)
end
end
describe 'user has a personal project' do
let(:private_project) { create(:project, :private, namespace: user.namespace, creator: user) { |p| p.add_maintainer(user) } }
let!(:private_event) { create(:event, project: private_project, author: user) }
include_context 'visit overview tab'
it 'it shows one entry in the list of projects' do
page.within('.projects-block') do
expect(page).to have_selector('.project-row', count: 1)
end
end
it 'shows a link to the project list' do
expect(find('#js-overview .projects-block')).to have_selector('.js-view-all', visible: true)
end
end
end
end
......@@ -8,6 +8,7 @@ describe 'User page' do
visit(user_path(user))
page.within '.nav-links' do
expect(page).to have_link('Overview')
expect(page).to have_link('Activity')
expect(page).to have_link('Groups')
expect(page).to have_link('Contributed projects')
......@@ -44,6 +45,7 @@ describe 'User page' do
visit(user_path(user))
page.within '.nav-links' do
expect(page).to have_link('Overview')
expect(page).to have_link('Activity')
expect(page).to have_link('Groups')
expect(page).to have_link('Contributed projects')
......
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