Commit ca70f411 authored by Roger Meier's avatar Roger Meier Committed by Simon Knox

Add follow each other model, API and UI(profile, activity view)

parent bfddef57
......@@ -141,7 +141,15 @@ export default class UserTabs {
this.loadOverviewTab();
}
const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets'];
const loadableActions = [
'groups',
'contributed',
'projects',
'starred',
'snippets',
'followers',
'following',
];
if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint);
}
......
......@@ -33,6 +33,21 @@ class DashboardController < Dashboard::ApplicationController
protected
def load_events
@events =
if params[:filter] == "followed"
load_user_events
else
load_project_events
end
Events::RenderService.new(current_user).execute(@events)
end
def load_user_events
UserRecentEventsFinder.new(current_user, current_user.followees, event_filter, params).execute
end
def load_project_events
projects =
if params[:filter] == "starred"
ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
......@@ -40,12 +55,10 @@ class DashboardController < Dashboard::ApplicationController
current_user.authorized_projects
end
@events = EventCollection
EventCollection
.new(projects, offset: params[:offset].to_i, filter: event_filter)
.to_a
.map(&:present)
Events::RenderService.new(current_user).execute(@events)
end
def set_show_full_reference
......
# frozen_string_literal: true
class UsersController < ApplicationController
include InternalRedirect
include RoutableActions
include RendersMemberAccess
include RendersProjectsList
......@@ -13,13 +14,15 @@ class UsersController < ApplicationController
contributed: false,
snippets: true,
calendar: false,
followers: false,
following: false,
calendar_activities: true
skip_before_action :authenticate_user!
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists, :suggests, :ssh_keys]
before_action :authorize_read_user_profile!,
only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets]
only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following]
feature_category :users
......@@ -97,6 +100,18 @@ class UsersController < ApplicationController
present_projects(@starred_projects)
end
def followers
@user_followers = user.followers.page(params[:page])
present_users(@user_followers)
end
def following
@user_following = user.followees.page(params[:page])
present_users(@user_following)
end
def present_projects(projects)
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
......@@ -146,6 +161,22 @@ class UsersController < ApplicationController
render json: { exists: exists, suggests: suggestions }
end
def follow
current_user.follow(user)
redirect_path = referer_path(request) || @user
redirect_to redirect_path
end
def unfollow
current_user.unfollow(user)
redirect_path = referer_path(request) || @user
redirect_to redirect_path
end
private
def user
......@@ -169,7 +200,7 @@ class UsersController < ApplicationController
end
def load_events
@events = UserRecentEventsFinder.new(current_user, user, params).execute
@events = UserRecentEventsFinder.new(current_user, user, nil, params).execute
Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
......@@ -216,6 +247,17 @@ class UsersController < ApplicationController
def authorize_read_user_profile!
access_denied! unless can?(current_user, :read_user_profile, user)
end
def present_users(users)
respond_to do |format|
format.html { render 'show' }
format.json do
render json: {
html: view_to_html_string("shared/users/index", users: users)
}
end
end
end
end
UsersController.prepend_if_ee('EE::UsersController')
......@@ -15,27 +15,49 @@ class UserRecentEventsFinder
requires_cross_project_access
attr_reader :current_user, :target_user, :params
attr_reader :current_user, :target_user, :params, :event_filter
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
def initialize(current_user, target_user, params = {})
def initialize(current_user, target_user, event_filter, params = {})
@current_user = current_user
@target_user = target_user
@params = params
@event_filter = event_filter || EventFilter.new(EventFilter::ALL)
end
def execute
if target_user.is_a? User
execute_single
else
execute_multi
end
end
private
def execute_single
return Event.none unless can?(current_user, :read_user_profile, target_user)
target_events
event_filter.apply_filter(target_events
.with_associations
.limit_recent(limit, params[:offset])
.order_created_desc
.order_created_desc)
end
private
# rubocop: disable CodeReuse/ActiveRecord
def execute_multi
users = []
@target_user.each do |user|
users.append(user.id) if can?(current_user, :read_user_profile, user)
end
return Event.none if users.empty?
event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def target_events
......
......@@ -242,7 +242,7 @@ module UsersHelper
tabs = []
if can?(current_user, :read_user_profile, @user)
tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets]
tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets, :followers, :following]
end
tabs
......
......@@ -116,6 +116,13 @@ class User < ApplicationRecord
has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
# Followers
has_many :followed_users, foreign_key: :follower_id, class_name: 'Users::UserFollowUser'
has_many :followees, through: :followed_users
has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser'
has_many :followers, through: :following_users
# Groups
has_many :members
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, source: 'GroupMember'
......@@ -1442,6 +1449,29 @@ class User < ApplicationRecord
end
end
def following?(user)
self.followees.exists?(user.id)
end
def follow(user)
return false if self.id == user.id
begin
followee = Users::UserFollowUser.create(follower_id: self.id, followee_id: user.id)
self.followees.reset if followee.persisted?
rescue ActiveRecord::RecordNotUnique
false
end
end
def unfollow(user)
if Users::UserFollowUser.where(follower_id: self.id, followee_id: user.id).delete_all > 0
self.followees.reset
else
false
end
end
def manageable_namespaces
@manageable_namespaces ||= [namespace] + manageable_groups
end
......
# frozen_string_literal: true
module Users
class UserFollowUser < ApplicationRecord
belongs_to :follower, class_name: 'User'
belongs_to :followee, class_name: 'User'
end
end
......@@ -5,7 +5,10 @@
%ul.nav-links.nav.nav-tabs
%li{ class: active_when(params[:filter].nil?) }>
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
Your projects
= _('Your projects')
%li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
Starred projects
= _('Starred projects')
%li{ class: active_when(params[:filter] == 'followed') }>
= link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do
= _('Followed users')
- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
- primary_button_link = local_assigns.fetch(:primary_button_link, nil)
.nothing-here-block
.svg-content
......
- user = local_assigns.fetch(:user)
.col-lg-3.col-md-4.col-sm-12
.gl-card.gl-mb-5
.gl-card-body
= image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: ''
.user-info
.block-truncated
= link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id }
.block-truncated
%span.gl-text-gray-900= user.to_reference
- followers_illustration_path = 'illustrations/starred_empty.svg'
- followers_visitor_empty_message = s_('UserProfile|This user doesn\'t have any followers.')
- followers_current_user_empty_message_header = s_('UserProfile|You do not have any followers.')
- following_illustration_path = 'illustrations/starred_empty.svg'
- following_visitor_empty_message = s_('UserProfile|This user isn\'t following other users.')
- following_current_user_empty_message_header = s_('UserProfile|You are not following other users.')
- if users.size > 0
.row.gl-mt-3
= render partial: 'shared/users/user', collection: users, as: :user
= paginate users, theme: 'gitlab'
- else
- if @user_followers
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: followers_illustration_path,
visitor_empty_message: followers_visitor_empty_message,
current_user_empty_message_header: followers_current_user_empty_message_header}
- elsif @user_following
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: following_illustration_path,
visitor_empty_message: following_visitor_empty_message,
current_user_empty_message_header: following_current_user_empty_message_header}
......@@ -26,6 +26,13 @@
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn gl-button btn-default btn-icon',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('error')
- if current_user && current_user.id != @user.id
- if current_user.following?(@user)
= link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
= _('Unfollow')
- else
= link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
= _('Follow')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
......@@ -89,6 +96,16 @@
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
= link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link', itemprop: 'email'
.cover-desc.gl-text-gray-900.gl-mb-2.mb-sm-2
= sprite_icon('users', css_class: 'gl-vertical-align-middle gl-text-gray-500')
.profile-link-holder.middle-dot-divider
= link_to user_followers_path, class: 'text-link' do
- count = @user.followers.count
= n_('1 follower', '%{count} followers', count) % { count: count }
.profile-link-holder.middle-dot-divider
= link_to user_following_path, class: 'text-link' do
= @user.followees.count
= _('following')
- if @user.bio.present?
.cover-desc.cgray
.profile-user-bio
......@@ -129,6 +146,14 @@
%li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
= s_('UserProfile|Snippets')
- if profile_tab?(:followers)
%li.js-followers-tab
= link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do
= s_('UserProfile|Followers')
- if profile_tab?(:following)
%li.js-following-tab
= link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json) } do
= s_('UserProfile|Following')
%div{ class: container_class }
.tab-content
......@@ -165,6 +190,14 @@
#snippets.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:followers)
#followers.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:following)
#following.tab-pane
-# This tab is always loaded via AJAX
.loading.hide
.spinner.spinner-md
......
---
title: Add follow each other model, API and UI(profile, activity view)
merge_request: 45451
author: Roger Meier
type: added
......@@ -46,9 +46,13 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :contributed, as: :contributed_projects
get :starred, as: :starred_projects
get :snippets
get :followers
get :following
get :exists
get :suggests
get :activity
post :follow
post :unfollow
get '/', to: redirect('%{username}'), as: nil
end
end
......
# frozen_string_literal: true
class CreateUserFollowUsers < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
execute <<~SQL
CREATE TABLE user_follow_users (
follower_id integer not null references users (id) on delete cascade,
followee_id integer not null references users (id) on delete cascade,
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX ON user_follow_users (followee_id);
SQL
end
end
def down
drop_table :user_follow_users
end
end
d6b324e808265c4ba8b6216c77b7abfa96b4b8b4c9fbd8d0a15240548526c4f3
\ No newline at end of file
......@@ -17751,6 +17751,11 @@ CREATE SEQUENCE user_details_user_id_seq
ALTER SEQUENCE user_details_user_id_seq OWNED BY user_details.user_id;
CREATE TABLE user_follow_users (
follower_id integer NOT NULL,
followee_id integer NOT NULL
);
CREATE TABLE user_highest_roles (
user_id bigint NOT NULL,
updated_at timestamp with time zone NOT NULL,
......@@ -20917,6 +20922,9 @@ ALTER TABLE ONLY user_custom_attributes
ALTER TABLE ONLY user_details
ADD CONSTRAINT user_details_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY user_follow_users
ADD CONSTRAINT user_follow_users_pkey PRIMARY KEY (follower_id, followee_id);
ALTER TABLE ONLY user_highest_roles
ADD CONSTRAINT user_highest_roles_pkey PRIMARY KEY (user_id);
......@@ -23774,6 +23782,8 @@ CREATE UNIQUE INDEX uniq_pkgs_debian_project_distributions_project_id_and_suite
CREATE UNIQUE INDEX unique_merge_request_metrics_by_merge_request_id ON merge_request_metrics USING btree (merge_request_id);
CREATE INDEX user_follow_users_followee_id_idx ON user_follow_users USING btree (followee_id);
CREATE UNIQUE INDEX vulnerability_feedback_unique_idx ON vulnerability_feedback USING btree (project_id, category, feedback_type, project_fingerprint);
CREATE UNIQUE INDEX vulnerability_occurrence_pipelines_on_unique_keys ON vulnerability_occurrence_pipelines USING btree (occurrence_id, pipeline_id);
......@@ -26194,6 +26204,12 @@ ALTER TABLE ONLY u2f_registrations
ADD CONSTRAINT fk_u2f_registrations_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE product_analytics_events_experimental
ADD CONSTRAINT product_analytics_events_experimental_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;-- schema_migrations.version information is no longer stored in this file,
ADD CONSTRAINT product_analytics_events_experimental_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_follow_users
ADD CONSTRAINT user_follow_users_followee_id_fkey FOREIGN KEY (followee_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_follow_users
ADD CONSTRAINT user_follow_users_follower_id_fkey FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE;-- schema_migrations.version information is no longer stored in this file,
-- but instead tracked in the db/schema_migrations directory
-- see https://gitlab.com/gitlab-org/gitlab/-/issues/218590 for details
......@@ -274,7 +274,9 @@ Parameters:
"twitter": "",
"website_url": "",
"organization": "",
"job_title": "Operations Specialist"
"job_title": "Operations Specialist",
"followers": 1,
"following": 1
}
```
......@@ -685,6 +687,88 @@ Example responses
}
```
## User Follow
### Follow and unfollow users
Follow a user.
```plaintext
POST /users/:id/follow
```
Unfollow a user.
```plaintext
POST /users/:id/unfollow
```
| Attribute | Type | Required | Description |
| --------- | ------- | -------- | ---------------------------- |
| `id` | integer | yes | The ID of the user to follow |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/users/3/follow"
```
Example response:
```json
{
"id": 1,
"username": "john_smith",
"name": "John Smith",
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg",
"web_url": "http://localhost:3000/john_smith"
}
```
### Followers and following
Get the followers of a user.
```plaintext
GET /users/:id/followers
```
Get the list of users being followed.
```plaintext
GET /users/:id/following
```
| Attribute | Type | Required | Description |
| --------- | ------- | -------- | ---------------------------- |
| `id` | integer | yes | The ID of the user to follow |
```shell
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/users/3/followers"
```
Example response:
```json
[
{
"id": 2,
"name": "Lennie Donnelly",
"username": "evette.kilback",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/7955171a55ac4997ed81e5976287890a?s=80&d=identicon",
"web_url": "http://127.0.0.1:3000/evette.kilback"
},
{
"id": 4,
"name": "Serena Bradtke",
"username": "cammy",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/a2daad869a7b60d3090b7b9bef4baf57?s=80&d=identicon",
"web_url": "http://127.0.0.1:3000/cammy"
}
]
```
## User counts
Get the counts (same as in top right menu) of the currently signed in user.
......
......@@ -83,6 +83,14 @@ There are several types of users in GitLab:
self-managed instances' features and settings.
- [Internal users](../development/internal_users.md).
## User activity
You can follow or unfollow other users from their [user profiles](profile/index.md#user-profile).
To see their activity in the top-level Activity view, select Follow or Unfollow, and select
the Followed Users tab:
![Follow users](img/activity_followed_users_v13_9.png)
## Projects
In GitLab, you can create [projects](project/index.md) to host
......
......@@ -41,6 +41,12 @@ On your profile page, you can see the following information:
- Personal projects: your personal projects (respecting the project's visibility level)
- Starred projects: projects you starred
- Snippets: your personal code [snippets](../snippets.md#personal-snippets)
- Followers: people following you
- Following: people you are following
Profile page with active Following view:
![Follow users](img/profile_following_v13_9.png)
## User settings
......
......@@ -12,7 +12,7 @@ RSpec.describe UserRecentEventsFinder do
let_it_be(:public_event) { create(:event, :commented, target: note, author: user, project: nil) }
let_it_be(:private_event) { create(:event, :closed, target: private_epic, author: user, project: nil) }
subject { described_class.new(current_user, user, {}).execute }
subject { described_class.new(current_user, user, nil, {}).execute }
context 'epic related activities' do
context 'when profile is public' do
......
......@@ -10,6 +10,12 @@ module API
expose :work_information do |user|
work_information(user)
end
expose :followers, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) } do |user|
user.followers.count
end
expose :following, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) } do |user|
user.followees.count
end
end
end
end
......@@ -159,6 +159,68 @@ module API
present user.status || {}, with: Entities::UserStatus
end
desc 'Follow a user' do
success Entities::User
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
end
post ':id/follow', feature_category: :users do
user = find_user(params[:id])
not_found!('User') unless user
if current_user.follow(user)
present user, with: Entities::UserBasic
else
not_modified!
end
end
desc 'Unfollow a user' do
success Entities::User
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
end
post ':id/unfollow', feature_category: :users do
user = find_user(params[:id])
not_found!('User') unless user
if current_user.unfollow(user)
present user, with: Entities::UserBasic
else
not_modified!
end
end
desc 'Get the users who follow a user' do
success Entities::UserBasic
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
use :pagination
end
get ':id/following', feature_category: :users do
user = find_user(params[:id])
not_found!('User') unless user && can?(current_user, :read_user_profile, user)
present paginate(user.followees), with: Entities::UserBasic
end
desc 'Get the followers of a user' do
success Entities::UserBasic
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
use :pagination
end
get ':id/followers', feature_category: :users do
user = find_user(params[:id])
not_found!('User') unless user && can?(current_user, :read_user_profile, user)
present paginate(user.followers), with: Entities::UserBasic
end
desc 'Create a user. Available only for admins.' do
success Entities::UserWithAdmin
end
......
......@@ -1123,6 +1123,11 @@ msgid_plural "%d deploy keys"
msgstr[0] ""
msgstr[1] ""
msgid "1 follower"
msgid_plural "%{count} followers"
msgstr[0] ""
msgstr[1] ""
msgid "1 group"
msgid_plural "%d groups"
msgstr[0] ""
......@@ -12971,6 +12976,12 @@ msgstr ""
msgid "Folder/%{name}"
msgstr ""
msgid "Follow"
msgstr ""
msgid "Followed users"
msgstr ""
msgid "Font Color"
msgstr ""
......@@ -31381,6 +31392,9 @@ msgstr ""
msgid "Unexpected error"
msgstr ""
msgid "Unfollow"
msgstr ""
msgid "Unfortunately, your email message to GitLab could not be processed."
msgstr ""
......@@ -32038,6 +32052,12 @@ msgstr ""
msgid "UserProfile|Explore public groups to find projects to contribute to."
msgstr ""
msgid "UserProfile|Followers"
msgstr ""
msgid "UserProfile|Following"
msgstr ""
msgid "UserProfile|Groups"
msgstr ""
......@@ -32080,6 +32100,9 @@ msgstr ""
msgid "UserProfile|Subscribe"
msgstr ""
msgid "UserProfile|This user doesn't have any followers."
msgstr ""
msgid "UserProfile|This user doesn't have any personal projects"
msgstr ""
......@@ -32095,6 +32118,9 @@ msgstr ""
msgid "UserProfile|This user is blocked"
msgstr ""
msgid "UserProfile|This user isn't following other users."
msgstr ""
msgid "UserProfile|Unconfirmed user"
msgstr ""
......@@ -32104,9 +32130,15 @@ msgstr ""
msgid "UserProfile|View user in admin area"
msgstr ""
msgid "UserProfile|You are not following other users."
msgstr ""
msgid "UserProfile|You can create a group for several dependent projects."
msgstr ""
msgid "UserProfile|You do not have any followers."
msgstr ""
msgid "UserProfile|You haven't created any personal projects."
msgstr ""
......@@ -34672,6 +34704,9 @@ msgstr[1] ""
msgid "finding is not found or is already attached to a vulnerability"
msgstr ""
msgid "following"
msgstr ""
msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}"
msgstr ""
......
......@@ -9,6 +9,26 @@ RSpec.describe 'Dashboard > Activity' do
sign_in(user)
end
context 'tabs' do
it 'shows Your Projects' do
visit activity_dashboard_path
expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects')
end
it 'shows Starred Projects' do
visit activity_dashboard_path(filter: 'starred')
expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects')
end
it 'shows Followed Projects' do
visit activity_dashboard_path(filter: 'followed')
expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users')
end
end
context 'rss' do
before do
visit activity_dashboard_path
......
......@@ -151,6 +151,132 @@ RSpec.describe 'Overview tab on a user profile', :js do
end
end
describe 'followers section' do
describe 'user has no followers' do
before do
visit user.username
page.find('.js-followers-tab a').click
wait_for_requests
end
it 'shows an empty followers list with an info message' do
page.within('#followers') do
expect(page).to have_content('You do not have any followers')
expect(page).not_to have_selector('.gl-card.gl-mb-5')
expect(page).not_to have_selector('.gl-pagination')
end
end
end
describe 'user has less then 20 followers' do
let(:follower) { create(:user) }
before do
follower.follow(user)
visit user.username
page.find('.js-followers-tab a').click
wait_for_requests
end
it 'shows followers' do
page.within('#followers') do
expect(page).to have_content(follower.name)
expect(page).to have_selector('.gl-card.gl-mb-5')
expect(page).not_to have_selector('.gl-pagination')
end
end
end
describe 'user has more then 20 followers' do
let(:other_users) { create_list(:user, 21) }
before do
other_users.each do |follower|
follower.follow(user)
end
visit user.username
page.find('.js-followers-tab a').click
wait_for_requests
end
it 'shows paginated followers' do
page.within('#followers') do
other_users.each_with_index do |follower, i|
break if i == 20
expect(page).to have_content(follower.name)
end
expect(page).to have_selector('.gl-card.gl-mb-5')
expect(page).to have_selector('.gl-pagination')
expect(page).to have_selector('.gl-pagination .js-pagination-page', count: 2)
end
end
end
end
describe 'following section' do
describe 'user is not following others' do
before do
visit user.username
page.find('.js-following-tab a').click
wait_for_requests
end
it 'shows an empty following list with an info message' do
page.within('#following') do
expect(page).to have_content('You are not following other users')
expect(page).not_to have_selector('.gl-card.gl-mb-5')
expect(page).not_to have_selector('.gl-pagination')
end
end
end
describe 'user is following less then 20 people' do
let(:followee) { create(:user) }
before do
user.follow(followee)
visit user.username
page.find('.js-following-tab a').click
wait_for_requests
end
it 'shows following user' do
page.within('#following') do
expect(page).to have_content(followee.name)
expect(page).to have_selector('.gl-card.gl-mb-5')
expect(page).not_to have_selector('.gl-pagination')
end
end
end
describe 'user is following more then 20 people' do
let(:other_users) { create_list(:user, 21) }
before do
other_users.each do |followee|
user.follow(followee)
end
visit user.username
page.find('.js-following-tab a').click
wait_for_requests
end
it 'shows paginated following' do
page.within('#following') do
other_users.each_with_index do |followee, i|
break if i == 20
expect(page).to have_content(followee.name)
end
expect(page).to have_selector('.gl-card.gl-mb-5')
expect(page).to have_selector('.gl-pagination')
expect(page).to have_selector('.gl-pagination .js-pagination-page', count: 2)
end
end
end
end
describe 'bot user' do
let(:bot_user) { create(:user, user_type: :security_bot) }
......
......@@ -20,6 +20,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
expect(page).to have_link('Followers')
expect(page).to have_link('Following')
end
end
......@@ -54,6 +56,50 @@ RSpec.describe 'User page' do
expect(page).to have_content('GitLab - work info test')
end
end
context 'follow/unfollow and followers/following' do
let_it_be(:followee) { create(:user) }
let_it_be(:follower) { create(:user) }
it 'does not show link to follow' do
subject
expect(page).not_to have_link(text: 'Follow', class: 'gl-button')
end
it 'shows 0 followers and 0 following' do
subject
expect(page).to have_content('0 followers')
expect(page).to have_content('0 following')
end
it 'shows 1 followers and 1 following' do
follower.follow(user)
user.follow(followee)
subject
expect(page).to have_content('1 follower')
expect(page).to have_content('1 following')
end
it 'does show link to follow' do
sign_in(user)
visit user_path(followee)
expect(page).to have_link(text: 'Follow', class: 'gl-button')
end
it 'does show link to unfollow' do
sign_in(user)
user.follow(followee)
visit user_path(followee)
expect(page).to have_link(text: 'Unfollow', class: 'gl-button')
end
end
end
context 'with private profile' do
......@@ -83,6 +129,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
expect(page).to have_link('Followers')
expect(page).to have_link('Following')
end
end
end
......@@ -242,6 +290,8 @@ RSpec.describe 'User page' do
expect(page).not_to have_link('Contributed projects')
expect(page).not_to have_link('Personal projects')
expect(page).not_to have_link('Snippets')
expect(page).not_to have_link('Followers')
expect(page).not_to have_link('Following')
end
end
end
......@@ -261,6 +311,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
expect(page).to have_link('Followers')
expect(page).to have_link('Following')
end
end
end
......
......@@ -5,16 +5,17 @@ require 'spec_helper'
RSpec.describe UserRecentEventsFinder do
let_it_be(:project_owner, reload: true) { create(:user) }
let_it_be(:current_user, reload: true) { create(:user) }
let(:private_project) { create(:project, :private, creator: project_owner) }
let(:internal_project) { create(:project, :internal, creator: project_owner) }
let(:public_project) { create(:project, :public, creator: project_owner) }
let_it_be(:private_project) { create(:project, :private, creator: project_owner) }
let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) }
let_it_be(:public_project) { create(:project, :public, creator: project_owner) }
let!(:private_event) { create(:event, project: private_project, author: project_owner) }
let!(:internal_event) { create(:event, project: internal_project, author: project_owner) }
let!(:public_event) { create(:event, project: public_project, author: project_owner) }
let_it_be(:issue) { create(:issue, project: public_project) }
let(:limit) { nil }
let(:params) { { limit: limit } }
subject(:finder) { described_class.new(current_user, project_owner, params) }
subject(:finder) { described_class.new(current_user, project_owner, nil, params) }
describe '#execute' do
context 'when profile is public' do
......@@ -39,15 +40,106 @@ RSpec.describe UserRecentEventsFinder do
expect(finder.execute).to be_empty
end
describe 'design activity events' do
let_it_be(:event_a) { create(:design_event, author: project_owner) }
let_it_be(:event_b) { create(:design_event, author: project_owner) }
context 'events from multiple users' do
let_it_be(:second_user, reload: true) { create(:user) }
let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) }
let(:internal_project_second_user) { create(:project, :internal, creator: second_user) }
let(:public_project_second_user) { create(:project, :public, creator: second_user) }
let!(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) }
let!(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) }
let!(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) }
it 'includes events from all users', :aggregate_failures do
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user)
expect(events.size).to eq(6)
end
it 'does not include events from users with private profile', :aggregate_failures do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false)
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
expect(events).to include(private_event, internal_event, public_event)
expect(events.size).to eq(3)
end
end
context 'filter activity events' do
let!(:push_event) { create(:push_event, project: public_project, author: project_owner) }
let!(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) }
let!(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) }
let!(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) }
let!(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) }
let!(:design_event) { create(:design_event, project: public_project, author: project_owner) }
let!(:team_event) { create(:event, :joined, project: public_project, author: project_owner) }
it 'includes all events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::ALL)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
expect(events.size).to eq(10)
end
it 'only includes push events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::PUSH)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(push_event)
expect(events.size).to eq(1)
end
it 'only includes merge events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::MERGED)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(merge_event)
expect(events.size).to eq(1)
end
it 'only includes issue events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::ISSUE)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(issue_event)
expect(events.size).to eq(1)
end
it 'only includes comments events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::COMMENTS)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(comment_event)
expect(events.size).to eq(1)
end
it 'only includes wiki events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::WIKI)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(wiki_event)
expect(events.size).to eq(1)
end
it 'only includes design events', :aggregate_failures do
events = finder.execute
event_filter = EventFilter.new(EventFilter::DESIGNS)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(event_a)
expect(events).to include(event_b)
expect(events).to include(design_event)
expect(events.size).to eq(1)
end
it 'only includes team events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::TEAM)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event, team_event)
expect(events.size).to eq(4)
end
end
......
......@@ -2831,6 +2831,79 @@ RSpec.describe User do
end
end
describe '#following?' do
it 'check if following another user' do
user = create :user
followee1 = create :user
expect(user.follow(followee1)).to be_truthy
expect(user.following?(followee1)).to be_truthy
expect(user.unfollow(followee1)).to be_truthy
expect(user.following?(followee1)).to be_falsey
end
end
describe '#follow' do
it 'follow another user' do
user = create :user
followee1 = create :user
followee2 = create :user
expect(user.followees).to be_empty
expect(user.follow(followee1)).to be_truthy
expect(user.follow(followee1)).to be_falsey
expect(user.followees).to contain_exactly(followee1)
expect(user.follow(followee2)).to be_truthy
expect(user.follow(followee2)).to be_falsey
expect(user.followees).to contain_exactly(followee1, followee2)
end
it 'follow itself is not possible' do
user = create :user
expect(user.followees).to be_empty
expect(user.follow(user)).to be_falsey
expect(user.followees).to be_empty
end
end
describe '#unfollow' do
it 'unfollow another user' do
user = create :user
followee1 = create :user
followee2 = create :user
expect(user.followees).to be_empty
expect(user.follow(followee1)).to be_truthy
expect(user.follow(followee1)).to be_falsey
expect(user.follow(followee2)).to be_truthy
expect(user.follow(followee2)).to be_falsey
expect(user.followees).to contain_exactly(followee1, followee2)
expect(user.unfollow(followee1)).to be_truthy
expect(user.unfollow(followee1)).to be_falsey
expect(user.followees).to contain_exactly(followee2)
expect(user.unfollow(followee2)).to be_truthy
expect(user.unfollow(followee2)).to be_falsey
expect(user.followees).to be_empty
end
end
describe '.find_by_private_commit_email' do
context 'with email' do
let_it_be(:user) { create(:user) }
......
......@@ -652,6 +652,34 @@ RSpec.describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'created_at'
end
it "returns the `followers` field for public users" do
get api("/users/#{user.id}")
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).to include 'followers'
end
it "does not return the `followers` field for private users" do
get api("/users/#{private_user.id}")
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'followers'
end
it "returns the `following` field for public users" do
get api("/users/#{user.id}")
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).to include 'following'
end
it "does not return the `following` field for private users" do
get api("/users/#{private_user.id}")
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'following'
end
end
it "returns a 404 error if user id not found" do
......@@ -688,6 +716,128 @@ RSpec.describe API::Users do
end
end
describe 'POST /users/:id/follow' do
let(:followee) { create(:user) }
context 'on an unfollowed user' do
it 'follows the user' do
post api("/users/#{followee.id}/follow", user)
expect(user.followees).to contain_exactly(followee)
expect(response).to have_gitlab_http_status(:created)
end
end
context 'on a followed user' do
before do
user.follow(followee)
end
it 'does not change following' do
post api("/users/#{followee.id}/follow", user)
expect(user.followees).to contain_exactly(followee)
expect(response).to have_gitlab_http_status(:not_modified)
end
end
end
describe 'POST /users/:id/unfollow' do
let(:followee) { create(:user) }
context 'on a followed user' do
before do
user.follow(followee)
end
it 'unfollow the user' do
post api("/users/#{followee.id}/unfollow", user)
expect(user.followees).to be_empty
expect(response).to have_gitlab_http_status(:created)
end
end
context 'on an unfollowed user' do
it 'does not change following' do
post api("/users/#{followee.id}/unfollow", user)
expect(user.followees).to be_empty
expect(response).to have_gitlab_http_status(:not_modified)
end
end
end
describe 'GET /users/:id/followers' do
let(:follower) { create(:user) }
context 'user has followers' do
it 'lists followers' do
follower.follow(user)
get api("/users/#{user.id}/followers", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
end
it 'do not lists followers if profile is private' do
follower.follow(private_user)
get api("/users/#{private_user.id}/followers", user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
end
context 'user does not have any follower' do
it 'does list nothing' do
get api("/users/#{user.id}/followers", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_empty
end
end
end
describe 'GET /users/:id/following' do
let(:followee) { create(:user) }
context 'user has followers' do
it 'lists following user' do
user.follow(followee)
get api("/users/#{user.id}/following", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
end
it 'do not lists following user if profile is private' do
user.follow(private_user)
get api("/users/#{private_user.id}/following", user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
end
context 'user does not have any follower' do
it 'does list nothing' do
get api("/users/#{user.id}/following", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_empty
end
end
end
describe "POST /users" do
it "creates user" do
expect do
......
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