Commit 92c3078b authored by Phil Hughes's avatar Phil Hughes

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

Resolve "Add new "Overview" tab on user profile page"

Closes #49801

See merge request gitlab-org/gitlab-ce!21663
parents 14ed916c 337b2c80
......@@ -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 {
......
......@@ -29,11 +29,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
......@@ -53,9 +59,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
......@@ -125,6 +129,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
......@@ -45,6 +45,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :contributed, as: :contributed_projects
get :snippets
get :exists
get :activity
get '/', to: redirect('%{username}'), as: nil
end
......
......@@ -3345,6 +3345,9 @@ msgstr ""
msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
msgstr ""
msgid "Issues, merge requests, pushes and comments."
msgstr ""
msgid "Jan"
msgstr ""
......@@ -3709,6 +3712,9 @@ msgstr ""
msgid "Median"
msgstr ""
msgid "Member since %{date}"
msgstr ""
msgid "Members"
msgstr ""
......@@ -5753,6 +5759,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 ""
......@@ -6581,6 +6590,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