diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index e3aac07097bb1e275eddae3a69244be6df8efb24..80e14842f515bee0f17f5e9d019f4277627b0756 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -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); } diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a88cf64d8426b23b2c9323d05545010330b45e73..29cb60ad3ccdfc32a77a6d23332a160147f77322 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -33,6 +33,21 @@ def activity 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 @@ def load_events 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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c91521286386c348d43b2131a1d4df4012c258d3..54d97f588fca77e0a25c20b64ffb73d763842000 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,7 @@ # 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 @@ def starred 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 @@ def suggests 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 @@ def contributions_calendar 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 @@ def build_canonical_path(user) 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') diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index c9a1c918365766d830bfca853e47f9ddda2b2cb6..596a413782e5ba17b3f17d13e30e5228f12bbb3c 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -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 diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 1ea2d4412b1603ccb7bf0550599205bf0e055111..1979426f844ba0d43ab26a3be7930627d679c7dc 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -242,7 +242,7 @@ def get_profile_tabs 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 diff --git a/app/models/user.rb b/app/models/user.rb index 4a2ca64fbe97ef78e8addf4ba34fb4eb291576e2..1f8b680c7e5e475a75a1e2dacfda0602a76f62a1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -116,6 +116,13 @@ def update_tracked_fields!(request) 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 @@ def toggle_star(project) 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 diff --git a/app/models/users/user_follow_user.rb b/app/models/users/user_follow_user.rb new file mode 100644 index 0000000000000000000000000000000000000000..a94239a746c380e920db4ad1d5c7ec2ffcb9e62f --- /dev/null +++ b/app/models/users/user_follow_user.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module Users + class UserFollowUser < ApplicationRecord + belongs_to :follower, class_name: 'User' + belongs_to :followee, class_name: 'User' + end +end diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml index 3f39555a1d4af84aee99a7c54c99b100690ce1b0..0daadd20f54c20ad7406c1c96876078229a18cf3 100644 --- a/app/views/dashboard/_activity_head.html.haml +++ b/app/views/dashboard/_activity_head.html.haml @@ -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') diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml index 7780a144a26d83f3f4c98ca95d206d3c34fcbba4..abcf9740200a38d6c34f2254177295e51a91ba61 100644 --- a/app/views/shared/empty_states/_profile_tabs.html.haml +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -1,5 +1,6 @@ - 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 diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f92c12102bb338520b6d444dde97750668555603 --- /dev/null +++ b/app/views/shared/users/_user.html.haml @@ -0,0 +1,13 @@ +- 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 diff --git a/app/views/shared/users/index.html.haml b/app/views/shared/users/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..dd6b14d6be2f1ae1c494665dbe017f14454d4208 --- /dev/null +++ b/app/views/shared/users/index.html.haml @@ -0,0 +1,20 @@ +- 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} diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index debfe57dbd78fbc5560cce1ffb0b4736175c3a7f..cdaa739a7b36d06e74634aba56edd4ad3af456e5 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -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 diff --git a/changelogs/unreleased/feat-follow-eachother.yml b/changelogs/unreleased/feat-follow-eachother.yml new file mode 100644 index 0000000000000000000000000000000000000000..ba80e35535ead8961319e9e0eb079bb2065b345c --- /dev/null +++ b/changelogs/unreleased/feat-follow-eachother.yml @@ -0,0 +1,5 @@ +--- +title: Add follow each other model, API and UI(profile, activity view) +merge_request: 45451 +author: Roger Meier +type: added diff --git a/config/routes/user.rb b/config/routes/user.rb index 515a9a23360c5ea28478f75476dea36a80d3a628..41319b6d730d13ae38bcbd0fff1449c9cd02e93e 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -46,9 +46,13 @@ def override_omniauth(provider, controller, path_prefix = '/users/auth') 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 diff --git a/db/migrate/20201027101010_create_user_follow_users.rb b/db/migrate/20201027101010_create_user_follow_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c1f831f3b25e2d8523083c05537bcc8f97faccf --- /dev/null +++ b/db/migrate/20201027101010_create_user_follow_users.rb @@ -0,0 +1,24 @@ +# 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 diff --git a/db/schema_migrations/20201027101010 b/db/schema_migrations/20201027101010 new file mode 100644 index 0000000000000000000000000000000000000000..68628373757f85a3be6f5b4082c889c907f27379 --- /dev/null +++ b/db/schema_migrations/20201027101010 @@ -0,0 +1 @@ +d6b324e808265c4ba8b6216c77b7abfa96b4b8b4c9fbd8d0a15240548526c4f3 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 91efc451e202c0f709cc27e3891f7e05bbda131f..5d27baaab020c99cdcf340b99466ce2cc1372ba2 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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 diff --git a/doc/api/users.md b/doc/api/users.md index d09070081293029a520499957069df596209ef41..60db3be5e8876df107ae9a57969e1c41432daa0c 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -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: " "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: " "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. diff --git a/doc/user/img/activity_followed_users_v13_9.png b/doc/user/img/activity_followed_users_v13_9.png new file mode 100644 index 0000000000000000000000000000000000000000..7f54f17821c3164bb8486d60e10c8e80e0e69bce Binary files /dev/null and b/doc/user/img/activity_followed_users_v13_9.png differ diff --git a/doc/user/index.md b/doc/user/index.md index 598c47963b52acf6b9d0aa440b79c5da85b45eb0..a678038507f8930b4fde0010c00338ca010a542b 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -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 diff --git a/doc/user/profile/img/profile_following_v13_9.png b/doc/user/profile/img/profile_following_v13_9.png new file mode 100644 index 0000000000000000000000000000000000000000..85d54ff3aad7934e8307a49c7fd9c1f2bc93b49f Binary files /dev/null and b/doc/user/profile/img/profile_following_v13_9.png differ diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index 1ca6f968e9055ddee1fffc3047f26c1471c5b0ab..d2cbf9e4acdbf224cdab438a38c9f8e7b88efe86 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -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 diff --git a/ee/spec/finders/ee/user_recent_events_finder_spec.rb b/ee/spec/finders/ee/user_recent_events_finder_spec.rb index 4fcd9d233fe49a5f211ec45cb0c29aeefe6704ac..d46f6d4f5227ce92e9cb3878c5959fa7e858af61 100644 --- a/ee/spec/finders/ee/user_recent_events_finder_spec.rb +++ b/ee/spec/finders/ee/user_recent_events_finder_spec.rb @@ -12,7 +12,7 @@ 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 diff --git a/lib/api/entities/user.rb b/lib/api/entities/user.rb index b392e7831e5633d28d615bd1cd64a781bdf9c418..248a86751d224f252ab1edad6621b24a570839f1 100644 --- a/lib/api/entities/user.rb +++ b/lib/api/entities/user.rb @@ -10,6 +10,12 @@ class User < UserBasic 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 diff --git a/lib/api/users.rb b/lib/api/users.rb index 0352ddfb214356515f3796cece3ebb44f1658a13..28274d5afb3f85a828a186b452e2c1f6cb18691c 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -159,6 +159,68 @@ def reorder_users(users) 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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 329ec718cf0e333d77e8e6f26c21873e0a7cd2ee..34825e7ed28d64e1dc872777024e949b9ba0d367 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb index b419a063858c813f39f312d24562f43512c4b6a9..e75e661b513726d6a37448112c665b9b7031b566 100644 --- a/spec/features/dashboard/activity_spec.rb +++ b/spec/features/dashboard/activity_spec.rb @@ -9,6 +9,26 @@ 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 diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb index 67216b045045db5d10ca9f1dfa2b301127095e59..902079b7b9345033044eaa7e3c27701162193c11 100644 --- a/spec/features/users/overview_spec.rb +++ b/spec/features/users/overview_spec.rb @@ -151,6 +151,132 @@ def push_code_contribution 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) } diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index 6aeb3023db89129a0dd7e1dede1d37a79f9177e2..a83728007007b213a72ddc8fe755e1bc485bba61 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -20,6 +20,8 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb index afebff5b5c92ae23fd10951e3f6391405f7400c5..5a9243d150d0946819016eca96a72db3d5621673 100644 --- a/spec/finders/user_recent_events_finder_spec.rb +++ b/spec/finders/user_recent_events_finder_spec.rb @@ -5,16 +5,17 @@ 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 @@ 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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2ce6dad1c61a191b74dae29f242f9e4e78b3f91c..860c015e16693956ad085e1c7f96d5acb00ad03a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2831,6 +2831,79 @@ 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) } diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index bd8e4a59195bad253b8b30d70a69e698d1d544fa..d70a8bd692d936fe58e1141515cbd68c74e78603 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -652,6 +652,34 @@ 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 @@ 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