Commit 73c949b8 authored by Simon Knox's avatar Simon Knox

Merge branch 'feat/follow-eachother' into 'master'

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

See merge request gitlab-org/gitlab!45451
parents a65bf5c7 ca70f411
...@@ -141,7 +141,15 @@ export default class UserTabs { ...@@ -141,7 +141,15 @@ export default class UserTabs {
this.loadOverviewTab(); this.loadOverviewTab();
} }
const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets']; const loadableActions = [
'groups',
'contributed',
'projects',
'starred',
'snippets',
'followers',
'following',
];
if (loadableActions.indexOf(action) > -1) { if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint); this.loadTab(action, endpoint);
} }
......
...@@ -33,6 +33,21 @@ class DashboardController < Dashboard::ApplicationController ...@@ -33,6 +33,21 @@ class DashboardController < Dashboard::ApplicationController
protected protected
def load_events 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 = projects =
if params[:filter] == "starred" if params[:filter] == "starred"
ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
...@@ -40,12 +55,10 @@ class DashboardController < Dashboard::ApplicationController ...@@ -40,12 +55,10 @@ class DashboardController < Dashboard::ApplicationController
current_user.authorized_projects current_user.authorized_projects
end end
@events = EventCollection EventCollection
.new(projects, offset: params[:offset].to_i, filter: event_filter) .new(projects, offset: params[:offset].to_i, filter: event_filter)
.to_a .to_a
.map(&:present) .map(&:present)
Events::RenderService.new(current_user).execute(@events)
end end
def set_show_full_reference def set_show_full_reference
......
# frozen_string_literal: true # frozen_string_literal: true
class UsersController < ApplicationController class UsersController < ApplicationController
include InternalRedirect
include RoutableActions include RoutableActions
include RendersMemberAccess include RendersMemberAccess
include RendersProjectsList include RendersProjectsList
...@@ -13,13 +14,15 @@ class UsersController < ApplicationController ...@@ -13,13 +14,15 @@ class UsersController < ApplicationController
contributed: false, contributed: false,
snippets: true, snippets: true,
calendar: false, calendar: false,
followers: false,
following: false,
calendar_activities: true calendar_activities: true
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists, :suggests, :ssh_keys] before_action :user, except: [:exists, :suggests, :ssh_keys]
before_action :authorize_read_user_profile!, 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 feature_category :users
...@@ -97,6 +100,18 @@ class UsersController < ApplicationController ...@@ -97,6 +100,18 @@ class UsersController < ApplicationController
present_projects(@starred_projects) present_projects(@starred_projects)
end 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) def present_projects(projects)
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination]) skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace]) skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
...@@ -146,6 +161,22 @@ class UsersController < ApplicationController ...@@ -146,6 +161,22 @@ class UsersController < ApplicationController
render json: { exists: exists, suggests: suggestions } render json: { exists: exists, suggests: suggestions }
end 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 private
def user def user
...@@ -169,7 +200,7 @@ class UsersController < ApplicationController ...@@ -169,7 +200,7 @@ class UsersController < ApplicationController
end end
def load_events 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?) Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end end
...@@ -216,6 +247,17 @@ class UsersController < ApplicationController ...@@ -216,6 +247,17 @@ class UsersController < ApplicationController
def authorize_read_user_profile! def authorize_read_user_profile!
access_denied! unless can?(current_user, :read_user_profile, user) access_denied! unless can?(current_user, :read_user_profile, user)
end 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 end
UsersController.prepend_if_ee('EE::UsersController') UsersController.prepend_if_ee('EE::UsersController')
...@@ -15,27 +15,49 @@ class UserRecentEventsFinder ...@@ -15,27 +15,49 @@ class UserRecentEventsFinder
requires_cross_project_access requires_cross_project_access
attr_reader :current_user, :target_user, :params attr_reader :current_user, :target_user, :params, :event_filter
DEFAULT_LIMIT = 20 DEFAULT_LIMIT = 20
MAX_LIMIT = 100 MAX_LIMIT = 100
def initialize(current_user, target_user, params = {}) def initialize(current_user, target_user, event_filter, params = {})
@current_user = current_user @current_user = current_user
@target_user = target_user @target_user = target_user
@params = params @params = params
@event_filter = event_filter || EventFilter.new(EventFilter::ALL)
end end
def execute 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) return Event.none unless can?(current_user, :read_user_profile, target_user)
target_events event_filter.apply_filter(target_events
.with_associations .with_associations
.limit_recent(limit, params[:offset]) .limit_recent(limit, params[:offset])
.order_created_desc .order_created_desc)
end 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 # rubocop: disable CodeReuse/ActiveRecord
def target_events def target_events
......
...@@ -242,7 +242,7 @@ module UsersHelper ...@@ -242,7 +242,7 @@ module UsersHelper
tabs = [] tabs = []
if can?(current_user, :read_user_profile, @user) 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 end
tabs tabs
......
...@@ -116,6 +116,13 @@ class User < ApplicationRecord ...@@ -116,6 +116,13 @@ class User < ApplicationRecord
has_one :user_synced_attributes_metadata, autosave: true has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role' 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 # Groups
has_many :members has_many :members
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, source: 'GroupMember' has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, source: 'GroupMember'
...@@ -1442,6 +1449,29 @@ class User < ApplicationRecord ...@@ -1442,6 +1449,29 @@ class User < ApplicationRecord
end end
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 def manageable_namespaces
@manageable_namespaces ||= [namespace] + manageable_groups @manageable_namespaces ||= [namespace] + manageable_groups
end 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 @@ ...@@ -5,7 +5,10 @@
%ul.nav-links.nav.nav-tabs %ul.nav-links.nav.nav-tabs
%li{ class: active_when(params[:filter].nil?) }> %li{ class: active_when(params[:filter].nil?) }>
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do = link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
Your projects = _('Your projects')
%li{ class: active_when(params[:filter] == 'starred') }> %li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do = 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) - current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil) - secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
- primary_button_link = local_assigns.fetch(:primary_button_link, nil)
.nothing-here-block .nothing-here-block
.svg-content .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 @@ ...@@ -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', = 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 title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('error') = 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) - 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', = 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 title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
...@@ -89,6 +96,16 @@ ...@@ -89,6 +96,16 @@
- unless @user.public_email.blank? - unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0 .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' = 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? - if @user.bio.present?
.cover-desc.cgray .cover-desc.cgray
.profile-user-bio .profile-user-bio
...@@ -129,6 +146,14 @@ ...@@ -129,6 +146,14 @@
%li.js-snippets-tab %li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
= s_('UserProfile|Snippets') = 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 } %div{ class: container_class }
.tab-content .tab-content
...@@ -165,6 +190,14 @@ ...@@ -165,6 +190,14 @@
#snippets.tab-pane #snippets.tab-pane
-# This tab is always loaded via AJAX -# 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 .loading.hide
.spinner.spinner-md .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 ...@@ -46,9 +46,13 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :contributed, as: :contributed_projects get :contributed, as: :contributed_projects
get :starred, as: :starred_projects get :starred, as: :starred_projects
get :snippets get :snippets
get :followers
get :following
get :exists get :exists
get :suggests get :suggests
get :activity get :activity
post :follow
post :unfollow
get '/', to: redirect('%{username}'), as: nil get '/', to: redirect('%{username}'), as: nil
end end
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 ...@@ -17751,6 +17751,11 @@ CREATE SEQUENCE user_details_user_id_seq
ALTER SEQUENCE user_details_user_id_seq OWNED BY user_details.user_id; 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 ( CREATE TABLE user_highest_roles (
user_id bigint NOT NULL, user_id bigint NOT NULL,
updated_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL,
...@@ -20917,6 +20922,9 @@ ALTER TABLE ONLY user_custom_attributes ...@@ -20917,6 +20922,9 @@ ALTER TABLE ONLY user_custom_attributes
ALTER TABLE ONLY user_details ALTER TABLE ONLY user_details
ADD CONSTRAINT user_details_pkey PRIMARY KEY (user_id); 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 ALTER TABLE ONLY user_highest_roles
ADD CONSTRAINT user_highest_roles_pkey PRIMARY KEY (user_id); 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 ...@@ -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 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_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); 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 ...@@ -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; ADD CONSTRAINT fk_u2f_registrations_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE product_analytics_events_experimental 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 -- but instead tracked in the db/schema_migrations directory
-- see https://gitlab.com/gitlab-org/gitlab/-/issues/218590 for details -- see https://gitlab.com/gitlab-org/gitlab/-/issues/218590 for details
...@@ -274,7 +274,9 @@ Parameters: ...@@ -274,7 +274,9 @@ Parameters:
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": "", "organization": "",
"job_title": "Operations Specialist" "job_title": "Operations Specialist",
"followers": 1,
"following": 1
} }
``` ```
...@@ -685,6 +687,88 @@ Example responses ...@@ -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 ## User counts
Get the counts (same as in top right menu) of the currently signed in user. 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: ...@@ -83,6 +83,14 @@ There are several types of users in GitLab:
self-managed instances' features and settings. self-managed instances' features and settings.
- [Internal users](../development/internal_users.md). - [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 ## Projects
In GitLab, you can create [projects](project/index.md) to host 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: ...@@ -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) - Personal projects: your personal projects (respecting the project's visibility level)
- Starred projects: projects you starred - Starred projects: projects you starred
- Snippets: your personal code [snippets](../snippets.md#personal-snippets) - 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 ## User settings
......
...@@ -12,7 +12,7 @@ RSpec.describe UserRecentEventsFinder do ...@@ -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(: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) } 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 'epic related activities' do
context 'when profile is public' do context 'when profile is public' do
......
...@@ -10,6 +10,12 @@ module API ...@@ -10,6 +10,12 @@ module API
expose :work_information do |user| expose :work_information do |user|
work_information(user) work_information(user)
end 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 end
end end
...@@ -159,6 +159,68 @@ module API ...@@ -159,6 +159,68 @@ module API
present user.status || {}, with: Entities::UserStatus present user.status || {}, with: Entities::UserStatus
end 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 desc 'Create a user. Available only for admins.' do
success Entities::UserWithAdmin success Entities::UserWithAdmin
end end
......
...@@ -1123,6 +1123,11 @@ msgid_plural "%d deploy keys" ...@@ -1123,6 +1123,11 @@ msgid_plural "%d deploy keys"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 follower"
msgid_plural "%{count} followers"
msgstr[0] ""
msgstr[1] ""
msgid "1 group" msgid "1 group"
msgid_plural "%d groups" msgid_plural "%d groups"
msgstr[0] "" msgstr[0] ""
...@@ -12971,6 +12976,12 @@ msgstr "" ...@@ -12971,6 +12976,12 @@ msgstr ""
msgid "Folder/%{name}" msgid "Folder/%{name}"
msgstr "" msgstr ""
msgid "Follow"
msgstr ""
msgid "Followed users"
msgstr ""
msgid "Font Color" msgid "Font Color"
msgstr "" msgstr ""
...@@ -31384,6 +31395,9 @@ msgstr "" ...@@ -31384,6 +31395,9 @@ msgstr ""
msgid "Unexpected error" msgid "Unexpected error"
msgstr "" msgstr ""
msgid "Unfollow"
msgstr ""
msgid "Unfortunately, your email message to GitLab could not be processed." msgid "Unfortunately, your email message to GitLab could not be processed."
msgstr "" msgstr ""
...@@ -32041,6 +32055,12 @@ msgstr "" ...@@ -32041,6 +32055,12 @@ msgstr ""
msgid "UserProfile|Explore public groups to find projects to contribute to." msgid "UserProfile|Explore public groups to find projects to contribute to."
msgstr "" msgstr ""
msgid "UserProfile|Followers"
msgstr ""
msgid "UserProfile|Following"
msgstr ""
msgid "UserProfile|Groups" msgid "UserProfile|Groups"
msgstr "" msgstr ""
...@@ -32083,6 +32103,9 @@ msgstr "" ...@@ -32083,6 +32103,9 @@ msgstr ""
msgid "UserProfile|Subscribe" msgid "UserProfile|Subscribe"
msgstr "" msgstr ""
msgid "UserProfile|This user doesn't have any followers."
msgstr ""
msgid "UserProfile|This user doesn't have any personal projects" msgid "UserProfile|This user doesn't have any personal projects"
msgstr "" msgstr ""
...@@ -32098,6 +32121,9 @@ msgstr "" ...@@ -32098,6 +32121,9 @@ msgstr ""
msgid "UserProfile|This user is blocked" msgid "UserProfile|This user is blocked"
msgstr "" msgstr ""
msgid "UserProfile|This user isn't following other users."
msgstr ""
msgid "UserProfile|Unconfirmed user" msgid "UserProfile|Unconfirmed user"
msgstr "" msgstr ""
...@@ -32107,9 +32133,15 @@ msgstr "" ...@@ -32107,9 +32133,15 @@ msgstr ""
msgid "UserProfile|View user in admin area" msgid "UserProfile|View user in admin area"
msgstr "" msgstr ""
msgid "UserProfile|You are not following other users."
msgstr ""
msgid "UserProfile|You can create a group for several dependent projects." msgid "UserProfile|You can create a group for several dependent projects."
msgstr "" msgstr ""
msgid "UserProfile|You do not have any followers."
msgstr ""
msgid "UserProfile|You haven't created any personal projects." msgid "UserProfile|You haven't created any personal projects."
msgstr "" msgstr ""
...@@ -34675,6 +34707,9 @@ msgstr[1] "" ...@@ -34675,6 +34707,9 @@ msgstr[1] ""
msgid "finding is not found or is already attached to a vulnerability" msgid "finding is not found or is already attached to a vulnerability"
msgstr "" msgstr ""
msgid "following"
msgstr ""
msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}" msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}"
msgstr "" msgstr ""
......
...@@ -9,6 +9,26 @@ RSpec.describe 'Dashboard > Activity' do ...@@ -9,6 +9,26 @@ RSpec.describe 'Dashboard > Activity' do
sign_in(user) sign_in(user)
end 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 context 'rss' do
before do before do
visit activity_dashboard_path visit activity_dashboard_path
......
...@@ -151,6 +151,132 @@ RSpec.describe 'Overview tab on a user profile', :js do ...@@ -151,6 +151,132 @@ RSpec.describe 'Overview tab on a user profile', :js do
end end
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 describe 'bot user' do
let(:bot_user) { create(:user, user_type: :security_bot) } let(:bot_user) { create(:user, user_type: :security_bot) }
......
...@@ -20,6 +20,8 @@ RSpec.describe 'User page' do ...@@ -20,6 +20,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects') expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects') expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets') expect(page).to have_link('Snippets')
expect(page).to have_link('Followers')
expect(page).to have_link('Following')
end end
end end
...@@ -54,6 +56,50 @@ RSpec.describe 'User page' do ...@@ -54,6 +56,50 @@ RSpec.describe 'User page' do
expect(page).to have_content('GitLab - work info test') expect(page).to have_content('GitLab - work info test')
end end
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 end
context 'with private profile' do context 'with private profile' do
...@@ -83,6 +129,8 @@ RSpec.describe 'User page' do ...@@ -83,6 +129,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects') expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects') expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets') expect(page).to have_link('Snippets')
expect(page).to have_link('Followers')
expect(page).to have_link('Following')
end end
end end
end end
...@@ -242,6 +290,8 @@ RSpec.describe 'User page' do ...@@ -242,6 +290,8 @@ RSpec.describe 'User page' do
expect(page).not_to have_link('Contributed projects') expect(page).not_to have_link('Contributed projects')
expect(page).not_to have_link('Personal projects') expect(page).not_to have_link('Personal projects')
expect(page).not_to have_link('Snippets') expect(page).not_to have_link('Snippets')
expect(page).not_to have_link('Followers')
expect(page).not_to have_link('Following')
end end
end end
end end
...@@ -261,6 +311,8 @@ RSpec.describe 'User page' do ...@@ -261,6 +311,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects') expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects') expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets') expect(page).to have_link('Snippets')
expect(page).to have_link('Followers')
expect(page).to have_link('Following')
end end
end end
end end
......
...@@ -5,16 +5,17 @@ require 'spec_helper' ...@@ -5,16 +5,17 @@ require 'spec_helper'
RSpec.describe UserRecentEventsFinder do RSpec.describe UserRecentEventsFinder do
let_it_be(:project_owner, reload: true) { create(:user) } let_it_be(:project_owner, reload: true) { create(:user) }
let_it_be(:current_user, reload: true) { create(:user) } let_it_be(:current_user, reload: true) { create(:user) }
let(:private_project) { create(:project, :private, creator: project_owner) } let_it_be(:private_project) { create(:project, :private, creator: project_owner) }
let(:internal_project) { create(:project, :internal, creator: project_owner) } let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) }
let(:public_project) { create(:project, :public, 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!(:private_event) { create(:event, project: private_project, author: project_owner) }
let!(:internal_event) { create(:event, project: internal_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!(:public_event) { create(:event, project: public_project, author: project_owner) }
let_it_be(:issue) { create(:issue, project: public_project) }
let(:limit) { nil } let(:limit) { nil }
let(:params) { { limit: limit } } 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 describe '#execute' do
context 'when profile is public' do context 'when profile is public' do
...@@ -39,15 +40,106 @@ RSpec.describe UserRecentEventsFinder do ...@@ -39,15 +40,106 @@ RSpec.describe UserRecentEventsFinder do
expect(finder.execute).to be_empty expect(finder.execute).to be_empty
end end
describe 'design activity events' do context 'events from multiple users' do
let_it_be(:event_a) { create(:design_event, author: project_owner) } let_it_be(:second_user, reload: true) { create(:user) }
let_it_be(:event_b) { create(:design_event, author: project_owner) } 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 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(design_event)
expect(events).to include(event_b) 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
end end
......
...@@ -2831,6 +2831,79 @@ RSpec.describe User do ...@@ -2831,6 +2831,79 @@ RSpec.describe User do
end end
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 describe '.find_by_private_commit_email' do
context 'with email' do context 'with email' do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
......
...@@ -652,6 +652,34 @@ RSpec.describe API::Users do ...@@ -652,6 +652,34 @@ RSpec.describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/basic') expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'created_at' expect(json_response.keys).not_to include 'created_at'
end 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 end
it "returns a 404 error if user id not found" do it "returns a 404 error if user id not found" do
...@@ -688,6 +716,128 @@ RSpec.describe API::Users do ...@@ -688,6 +716,128 @@ RSpec.describe API::Users do
end end
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 describe "POST /users" do
it "creates user" do it "creates user" do
expect 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