summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStan Hu <stanhu@gmail.com>2019-08-08 04:40:55 +0000
committerStan Hu <stanhu@gmail.com>2019-08-08 04:40:55 +0000
commit3ad34c3a243cda19e73fbda0c9682e0a1c16c10a (patch)
tree38a419d054a0c999449d7837a311e28364e0cc42
parent4985f6e26d758e7d5e43c3525a4afe4a9459ce8e (diff)
parentd4078b535c9854695e770cdfb5e0f4846a8cf64a (diff)
downloadgitlab-ce-3ad34c3a243cda19e73fbda0c9682e0a1c16c10a.tar.gz
Merge branch '20137-starrers' into 'master'
Add possibilty to view starrers ("stargazers") of a repository & any user's starred repositories Closes #20137 See merge request gitlab-org/gitlab-ce!24690
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js2
-rw-r--r--app/assets/javascripts/star.js2
-rw-r--r--app/assets/stylesheets/pages/members.scss39
-rw-r--r--app/assets/stylesheets/pages/users.scss105
-rw-r--r--app/controllers/projects/starrers_controller.rb34
-rw-r--r--app/controllers/users_controller.rb41
-rw-r--r--app/finders/starred_projects_finder.rb11
-rw-r--r--app/finders/users_star_projects_finder.rb36
-rw-r--r--app/helpers/projects_helper.rb5
-rw-r--r--app/helpers/sorting_helper.rb17
-rw-r--r--app/helpers/users_helper.rb2
-rw-r--r--app/models/user.rb11
-rw-r--r--app/models/users_star_project.rb27
-rw-r--r--app/views/groups/group_members/index.html.haml4
-rw-r--r--app/views/projects/buttons/_star.html.haml6
-rw-r--r--app/views/projects/project_members/_team.html.haml4
-rw-r--r--app/views/projects/starrers/_starrer.html.haml19
-rw-r--r--app/views/projects/starrers/index.html.haml32
-rw-r--r--app/views/shared/members/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/projects/_list.html.haml31
-rw-r--r--app/views/shared/projects/_project.html.haml4
-rw-r--r--app/views/users/show.html.haml8
-rw-r--r--changelogs/unreleased/20137-starrers.yml5
-rw-r--r--config/routes/project.rb2
-rw-r--r--config/routes/user.rb1
-rw-r--r--doc/api/projects.md233
-rw-r--r--doc/user/profile/index.md2
-rw-r--r--lib/api/entities.rb5
-rw-r--r--lib/api/projects.rb29
-rw-r--r--locale/gitlab.pot30
-rw-r--r--spec/controllers/projects/starrers_controller_spec.rb110
-rw-r--r--spec/features/groups/members/search_members_spec.rb4
-rw-r--r--spec/features/groups/members/sort_members_spec.rb18
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb8
-rw-r--r--spec/features/projects/members/sorting_spec.rb18
-rw-r--r--spec/finders/starred_projects_finder_spec.rb41
-rw-r--r--spec/finders/users_star_projects_finder_spec.rb42
-rw-r--r--spec/helpers/users_helper_spec.rb2
-rw-r--r--spec/requests/api/projects_spec.rb101
39 files changed, 998 insertions, 95 deletions
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 1d8b388e935..4ac4efec45d 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -143,7 +143,7 @@ export default class UserTabs {
this.loadOverviewTab();
}
- const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
+ const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets'];
if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint);
}
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index 70f89152f70..97afeecd8ac 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -18,7 +18,7 @@ export default class Star {
const isStarred = $starSpan.hasClass('starred');
$this
.parent()
- .find('.star-count')
+ .find('.count')
.text(data.star_count);
if (isStarred) {
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 68af01f9ccc..d54d264bc5d 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -9,10 +9,6 @@
}
}
-.member-sort-dropdown {
- margin-left: $gl-padding-8;
-}
-
.member {
&.is-overridden {
.btn-ldap-override {
@@ -62,36 +58,9 @@
}
}
-.member-search-form {
- position: relative;
-
- @include media-breakpoint-up(sm) {
- float: right;
- }
-
- .dropdown {
- width: 100%;
- margin-top: 5px;
-
- .dropdown-menu-toggle {
- vertical-align: middle;
- width: 100%;
- }
-
- @include media-breakpoint-up(sm) {
- margin-top: 0;
- width: 155px;
- }
- }
-
- .form-control {
- width: 100%;
- padding-right: 35px;
-
- @include media-breakpoint-up(sm) {
- width: 250px;
- }
- }
+.member-access-text {
+ margin-left: auto;
+ line-height: 43px;
}
.member-search-btn {
@@ -177,7 +146,7 @@
padding-bottom: 1px;
}
- .flex-project-members-form {
+ .flex-users-form {
flex-wrap: nowrap;
white-space: nowrap;
margin-left: auto;
diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/pages/users.scss
new file mode 100644
index 00000000000..3b018c1e087
--- /dev/null
+++ b/app/assets/stylesheets/pages/users.scss
@@ -0,0 +1,105 @@
+.user-sort-dropdown {
+ margin-left: $gl-padding-8;
+}
+
+.user-search-form {
+ position: relative;
+
+ @include media-breakpoint-up(sm) {
+ float: right;
+ }
+
+ .dropdown {
+ width: 100%;
+ margin-top: 5px;
+
+ .dropdown-menu-toggle {
+ vertical-align: middle;
+ width: 100%;
+ }
+
+ @include media-breakpoint-up(sm) {
+ margin-top: 0;
+ width: 155px;
+ }
+ }
+
+ .form-control {
+ width: 100%;
+ padding-right: 35px;
+
+ @include media-breakpoint-up(sm) {
+ width: 250px;
+ }
+ }
+}
+
+.user-search-btn {
+ position: absolute;
+ right: 4px;
+ top: 0;
+ height: 35px;
+ padding-left: 10px;
+ padding-right: 10px;
+ color: $gray-darkest;
+ background: transparent;
+ border: 0;
+ outline: 0;
+}
+
+.flex-users-panel {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ @include media-breakpoint-down(sm) {
+ display: block;
+
+ .flex-project-title {
+ vertical-align: top;
+ display: inline-block;
+ max-width: 90%;
+ }
+ }
+
+ .flex-project-title {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .badge.badge-pill {
+ height: 17px;
+ line-height: 16px;
+ margin-right: 5px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ }
+
+ .flex-users-form {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ margin-left: auto;
+ }
+}
+
+.content-list.members-list li {
+ display: flex;
+ justify-content: space-between;
+
+ .list-item-name {
+ float: none;
+ display: flex;
+ flex: 1;
+ }
+}
+
+.card-body .user-info {
+ float: left;
+
+ .user {
+ color: $gl-text-color;
+ font-weight: $gl-font-weight-bold;
+ }
+}
diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb
new file mode 100644
index 00000000000..c8facea1d70
--- /dev/null
+++ b/app/controllers/projects/starrers_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class Projects::StarrersController < Projects::ApplicationController
+ include SortingHelper
+
+ def index
+ @starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute
+
+ # Normally the number of public starrers is equal to the number of visible
+ # starrers. We need to fix the counts in two cases: when the current user
+ # is an admin (and can see everything) and when the current user has a
+ # private profile and has starred the project (and can see itself).
+ @public_count =
+ if @current_user&.admin?
+ @starrers.with_public_profile.count
+ elsif @current_user&.private_profile && has_starred_project?(@starrers)
+ @starrers.size - 1
+ else
+ @starrers.size
+ end
+
+ @total_count = @project.starrers.size
+ @private_count = @total_count - @public_count
+
+ @sort = params[:sort].presence || sort_value_name
+ @starrers = @starrers.sort_by_attribute(@sort).page(params[:page])
+ end
+
+ private
+
+ def has_starred_project?(starrers)
+ starrers.first { |starrer| starrer.user_id == current_user.id }
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 072d62ddf38..91e0efcf45f 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -17,7 +17,7 @@ class UsersController < ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists]
before_action :authorize_read_user_profile!,
- only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :snippets]
+ only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :starred_projects, :snippets]
def show
respond_to do |format|
@@ -57,27 +57,30 @@ class UsersController < ApplicationController
def projects
load_projects
- skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
- skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
- compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
-
- respond_to do |format|
- format.html { render 'show' }
- format.json do
- pager_json("shared/projects/_list", @projects.count, projects: @projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
- end
- end
+ present_projects(@projects)
end
def contributed
load_contributed_projects
+ present_projects(@contributed_projects)
+ end
+
+ def starred
+ load_starred_projects
+
+ present_projects(@starred_projects)
+ end
+
+ def present_projects(projects)
+ skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
+ skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
+ compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
+
respond_to do |format|
format.html { render 'show' }
format.json do
- render json: {
- html: view_to_html_string("shared/projects/_list", projects: @contributed_projects)
- }
+ pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
end
end
end
@@ -120,6 +123,10 @@ class UsersController < ApplicationController
ContributedProjectsFinder.new(user).execute(current_user)
end
+ def starred_projects
+ StarredProjectsFinder.new(user, current_user: current_user).execute
+ end
+
def contributions_calendar
@contributions_calendar ||= Gitlab::ContributionsCalendar.new(user, current_user)
end
@@ -145,6 +152,12 @@ class UsersController < ApplicationController
prepare_projects_for_rendering(@contributed_projects)
end
+ def load_starred_projects
+ @starred_projects = starred_projects
+
+ prepare_projects_for_rendering(@starred_projects)
+ end
+
def load_groups
@groups = JoinedGroupsFinder.new(user).execute(current_user)
diff --git a/app/finders/starred_projects_finder.rb b/app/finders/starred_projects_finder.rb
new file mode 100644
index 00000000000..fcb469d1d17
--- /dev/null
+++ b/app/finders/starred_projects_finder.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class StarredProjectsFinder < ProjectsFinder
+ def initialize(user, params: {}, current_user: nil)
+ super(
+ params: params,
+ current_user: current_user,
+ project_ids_relation: user.starred_projects.select(:id)
+ )
+ end
+end
diff --git a/app/finders/users_star_projects_finder.rb b/app/finders/users_star_projects_finder.rb
new file mode 100644
index 00000000000..49c4e087b4b
--- /dev/null
+++ b/app/finders/users_star_projects_finder.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class UsersStarProjectsFinder
+ include CustomAttributesFilter
+
+ attr_accessor :params
+
+ def initialize(project, params = {}, current_user: nil)
+ @params = params
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute
+ stars = UsersStarProject.all
+ stars = by_project(stars)
+ stars = by_search(stars)
+ stars = filter_visible_profiles(stars)
+
+ stars
+ end
+
+ private
+
+ def by_search(items)
+ params[:search].present? ? items.search(params[:search]) : items
+ end
+
+ def by_project(items)
+ items.by_project(@project)
+ end
+
+ def filter_visible_profiles(items)
+ items.with_visible_profile(@current_user)
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8d0079a4dd3..71c9c121e48 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -601,6 +601,11 @@ module ProjectsHelper
end
end
+ def filter_starrer_path(options = {})
+ options = params.slice(:sort).merge(options).permit!
+ "#{request.path}?#{options.to_param}"
+ end
+
def sidebar_projects_paths
%w[
projects#show
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 15f35645c78..a4eb76a2359 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -167,6 +167,15 @@ module SortingHelper
}
end
+ def starrers_sort_options_hash
+ {
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_recently_created => sort_title_recently_starred,
+ sort_value_oldest_created => sort_title_oldest_starred
+ }
+ end
+
def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
@@ -327,6 +336,10 @@ module SortingHelper
s_('SortOptions|Oldest sign in')
end
+ def sort_title_oldest_starred
+ s_('SortOptions|Oldest starred')
+ end
+
def sort_title_oldest_updated
s_('SortOptions|Oldest updated')
end
@@ -347,6 +360,10 @@ module SortingHelper
s_('SortOptions|Recent sign in')
end
+ def sort_title_recently_starred
+ s_('SortOptions|Recently starred')
+ end
+
def sort_title_recently_updated
s_('SortOptions|Last updated')
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 73ca17c6605..e38e3378e07 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -89,7 +89,7 @@ module UsersHelper
tabs = []
if can?(current_user, :read_user_profile, @user)
- tabs += [:overview, :activity, :groups, :contributed, :projects, :snippets]
+ tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets]
end
tabs
diff --git a/app/models/user.rb b/app/models/user.rb
index 4630552e02e..ac83c8e3256 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -282,6 +282,17 @@ class User < ApplicationRecord
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
+ scope :with_public_profile, -> { where(private_profile: false) }
+
+ def self.with_visible_profile(user)
+ return with_public_profile if user.nil?
+
+ if user.admin?
+ all
+ else
+ with_public_profile.or(where(id: user.id))
+ end
+ end
# Limits the users to those that have TODOs, optionally in the given state.
#
diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb
index 9be6bd2e6f3..3c7a805cc5c 100644
--- a/app/models/users_star_project.rb
+++ b/app/models/users_star_project.rb
@@ -1,10 +1,37 @@
# frozen_string_literal: true
class UsersStarProject < ApplicationRecord
+ include Sortable
+
belongs_to :project, counter_cache: :star_count, touch: true
belongs_to :user
validates :user, presence: true
validates :user_id, uniqueness: { scope: [:project_id] }
validates :project, presence: true
+
+ alias_attribute :starred_since, :created_at
+
+ scope :order_user_name_asc, -> { joins(:user).merge(User.order_name_asc) }
+ scope :order_user_name_desc, -> { joins(:user).merge(User.order_name_desc) }
+ scope :by_project, -> (project) { where(project_id: project.id) }
+ scope :with_visible_profile, -> (user) { joins(:user).merge(User.with_visible_profile(user)) }
+ scope :with_public_profile, -> { joins(:user).merge(User.with_public_profile) }
+
+ class << self
+ def sort_by_attribute(method)
+ order_method = method || 'id_desc'
+
+ case order_method.to_s
+ when 'name_asc' then order_user_name_asc
+ when 'name_desc' then order_user_name_desc
+ else
+ order_by(order_method)
+ end
+ end
+
+ def search(query)
+ joins(:user).merge(User.search(query))
+ end
+ end
end
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 021c0b6c429..2b8c9f65d43 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -25,11 +25,11 @@
Members with access to
%strong= @group.name
%span.badge.badge-pill= @members.total_count
- = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
+ = form_tag group_group_members_path(@group), method: :get, class: 'form-inline user-search-form flex-users-form' do
.form-group
.position-relative.append-right-8
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
- %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" }
+ %button.user-search-btn{ type: "submit", "aria-label" => "Submit search" }
= icon("search")
- if can_manage_members
= render 'shared/members/filter_2fa_dropdown'
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 090d1549aa7..02e5297528b 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -8,7 +8,8 @@
= sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
- = @project.star_count
+ = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
+ = @project.star_count
- else
.count-badge.d-inline-flex.align-item-stretch.append-right-8
@@ -16,4 +17,5 @@
= sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
- = @project.star_count
+ = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
+ = @project.star_count
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index f220299ec30..5310c1fad01 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -6,11 +6,11 @@
%span.flex-project-title
= _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) }
%span.badge.badge-pill= members.total_count
- = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
+ = form_tag project_project_members_path(project), method: :get, class: 'form-inline user-search-form flex-users-form' do
.form-group
.position-relative
= search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false }
- %button.member-search-btn{ type: "submit", "aria-label" => _("Submit search") }
+ %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= icon("search")
= render 'shared/members/sort_dropdown'
%ul.content-list.members-list.qa-members-list
diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml
new file mode 100644
index 00000000000..377d62f8abd
--- /dev/null
+++ b/app/views/projects/starrers/_starrer.html.haml
@@ -0,0 +1,19 @@
+- starrer = local_assigns.fetch(:starrer)
+
+.col-lg-3.col-md-4.col-sm-12
+ .card
+ .card-body
+ = image_tag avatar_icon_for_user(starrer.user, 40), class: "avatar s40", alt: ''
+
+ .user-info
+ .block-truncated
+ = link_to starrer.user.name, user_path(starrer.user), class: 'user js-user-link', data: { user_id: starrer.user.id }
+
+ .block-truncated
+ %span.cgray= starrer.user.to_reference
+
+ - if starrer.user == current_user
+ %span.badge.badge-success.prepend-left-5= _("It's you")
+
+ .block-truncated
+ = time_ago_with_tooltip(starrer.starred_since)
diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml
new file mode 100644
index 00000000000..e55ed99f643
--- /dev/null
+++ b/app/views/projects/starrers/index.html.haml
@@ -0,0 +1,32 @@
+- page_title _("Starrers")
+
+.top-area.adjust
+ .nav-text
+ - full_count_title = "#{@public_count} public and #{@private_count} private"
+ #{pluralize(@total_count, 'starrer')}: #{full_count_title}
+ - if @starrers.size > 0 || params[:search].present?
+ .nav-controls
+ = form_tag request.original_url, method: :get, class: 'form-inline user-search-form flex-users-form' do
+ .form-group
+ .position-relative
+ = search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
+ %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
+ = icon("search")
+ .dropdown.inline.user-sort-dropdown
+ = dropdown_toggle(starrers_sort_options_hash[@sort], { toggle: 'dropdown' })
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = _("Sort by")
+ - starrers_sort_options_hash.each do |value, title|
+ %li
+ = link_to filter_starrer_path(sort: value), class: ("is-active" if @sort == value) do
+ = title
+- if @starrers.size > 0
+ .row.prepend-top-10
+ = render partial: 'starrer', collection: @starrers, as: :starrer
+ = paginate @starrers, theme: 'gitlab'
+- else
+ - if params[:search].present?
+ .nothing-here-block= _('No starrers matched your search')
+ - else
+ .nothing-here-block= _('Nobody has starred this repository yet')
diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml
index 59bdfb73e6e..f5ebab035db 100644
--- a/app/views/shared/members/_sort_dropdown.html.haml
+++ b/app/views/shared/members/_sort_dropdown.html.haml
@@ -1,4 +1,4 @@
-.dropdown.inline.member-sort-dropdown
+.dropdown.inline.user-sort-dropdown
= dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 67cb1aa549c..bb05658c719 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -17,15 +17,20 @@
- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
+- starred_projects_illustration_path = 'illustrations/starred_empty.svg'
+- starred_projects_current_user_empty_message_header = s_('UserProfile|Star projects to track their progress and show your appreciation.')
+- starred_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t starred any projects')
- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
- explore_page_empty_message = s_('UserProfile|Explore public groups to find projects to contribute to.')
-- primary_button_label = _('New project')
-- primary_button_link = new_project_path
-- secondary_button_label = _('Explore groups')
-- secondary_button_link = explore_groups_path
+- new_project_button_label = _('New project')
+- new_project_button_link = new_project_path
+- explore_projects_button_label = _('Explore projects')
+- explore_projects_button_link = explore_projects_path
+- explore_groups_button_label = _('Explore groups')
+- explore_groups_button_link = explore_groups_path
.js-projects-list-holder
- if any_projects?(projects)
@@ -48,15 +53,21 @@
- if @contributed_projects
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
- primary_button_label: primary_button_label,
- primary_button_link: primary_button_link,
- secondary_button_label: secondary_button_label,
- secondary_button_link: secondary_button_link,
+ primary_button_label: new_project_button_label,
+ primary_button_link: new_project_button_link,
+ secondary_button_label: explore_groups_button_label,
+ secondary_button_link: explore_groups_button_link,
visitor_empty_message: contributed_projects_visitor_empty_message }
+ - elsif @starred_projects
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: starred_projects_illustration_path,
+ current_user_empty_message_header: starred_projects_current_user_empty_message_header,
+ primary_button_label: explore_projects_button_label,
+ primary_button_link: explore_projects_button_link,
+ visitor_empty_message: starred_projects_visitor_empty_message }
- else
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
current_user_empty_message_header: own_projects_current_user_empty_message_header,
current_user_empty_message_description: own_projects_current_user_empty_message_description,
- primary_button_label: primary_button_label,
- primary_button_link: primary_button_link,
+ primary_button_label: new_project_button_label,
+ primary_button_link: new_project_button_link,
visitor_empty_message: defined?(explore_page) && explore_page ? explore_page_empty_message : own_projects_visitor_empty_message }
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index f40a9cffb29..b7474d891dc 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -63,7 +63,9 @@
- if project.archived
%span.d-flex.icon-wrapper.badge.badge-warning archived
- if stars
- %span.d-flex.align-items-center.icon-wrapper.stars.has-tooltip{ data: { container: 'body', placement: 'top' }, title: _('Stars') }
+ = link_to project_starrers_path(project),
+ class: "d-flex align-items-center icon-wrapper stars has-tooltip",
+ title: _('Stars'), data: { container: 'body', placement: 'top' } do
= sprite_icon('star', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.star_count)
- if forks
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index b3a73030859..73bee7c2586 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -111,6 +111,10 @@
%li.js-projects-tab
= link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
= s_('UserProfile|Personal projects')
+ - if profile_tab?(:starred)
+ %li.js-starred-tab
+ = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
+ = s_('UserProfile|Starred projects')
- if profile_tab?(:snippets)
%li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
@@ -142,6 +146,10 @@
#projects.tab-pane
-# This tab is always loaded via AJAX
+ - if profile_tab?(:starred)
+ #starred.tab-pane
+ -# This tab is always loaded via AJAX
+
- if profile_tab?(:snippets)
#snippets.tab-pane
-# This tab is always loaded via AJAX
diff --git a/changelogs/unreleased/20137-starrers.yml b/changelogs/unreleased/20137-starrers.yml
new file mode 100644
index 00000000000..d597b06f224
--- /dev/null
+++ b/changelogs/unreleased/20137-starrers.yml
@@ -0,0 +1,5 @@
+---
+title: Make starred projects and starrers of a project publicly visible
+merge_request: 24690
+author:
+type: added
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 3113cb172f7..380ecad001d 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -170,7 +170,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :recent
end
end
+
resources :releases, only: [:index]
+ resources :starrers, only: [:index]
resources :forks, only: [:index, :new, :create]
resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ }
diff --git a/config/routes/user.rb b/config/routes/user.rb
index 80f266aa8f9..3f768d5d384 100644
--- a/config/routes/user.rb
+++ b/config/routes/user.rb
@@ -59,6 +59,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :groups
get :projects
get :contributed, as: :contributed_projects
+ get :starred, as: :starred_projects
get :snippets
get :exists
get :activity
diff --git a/doc/api/projects.md b/doc/api/projects.md
index a17a32ea9df..70df44ec0fd 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -465,6 +465,194 @@ GET /users/:user_id/projects
]
```
+## List projects starred by a user
+
+Get a list of visible projects owned by the given user. When accessed without authentication, only public projects are returned.
+
+```
+GET /users/:user_id/starred_projects
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `user_id` | string | yes | The ID or username of the user. |
+| `archived` | boolean | no | Limit by archived status. |
+| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private`. |
+| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at`. |
+| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc`. |
+| `search` | string | no | Return list of projects matching the search criteria. |
+| `simple` | boolean | no | Return only limited fields for each project. This is a no-op without authentication as then _only_ simple fields are returned.. |
+| `owned` | boolean | no | Limit by projects explicitly owned by the current user. |
+| `membership` | boolean | no | Limit by projects that the current user is a member of. |
+| `starred` | boolean | no | Limit by projects starred by the current user. |
+| `statistics` | boolean | no | Include project statistics. |
+| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only). |
+| `with_issues_enabled` | boolean | no | Limit by enabled issues feature. |
+| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature. |
+| `min_access_level` | integer | no | Limit by current user minimal [access level](members.md). |
+
+```bash
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/users/5/starred_projects"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 4,
+ "description": null,
+ "default_branch": "master",
+ "visibility": "private",
+ "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git",
+ "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git",
+ "web_url": "http://example.com/diaspora/diaspora-client",
+ "readme_url": "http://example.com/diaspora/diaspora-client/blob/master/README.md",
+ "tag_list": [
+ "example",
+ "disapora client"
+ ],
+ "owner": {
+ "id": 3,
+ "name": "Diaspora",
+ "created_at": "2013-09-30T13:46:02Z"
+ },
+ "name": "Diaspora Client",
+ "name_with_namespace": "Diaspora / Diaspora Client",
+ "path": "diaspora-client",
+ "path_with_namespace": "diaspora/diaspora-client",
+ "issues_enabled": true,
+ "open_issues_count": 1,
+ "merge_requests_enabled": true,
+ "jobs_enabled": true,
+ "wiki_enabled": true,
+ "snippets_enabled": false,
+ "resolve_outdated_diff_discussions": false,
+ "container_registry_enabled": false,
+ "created_at": "2013-09-30T13:46:02Z",
+ "last_activity_at": "2013-09-30T13:46:02Z",
+ "creator_id": 3,
+ "namespace": {
+ "id": 3,
+ "name": "Diaspora",
+ "path": "diaspora",
+ "kind": "group",
+ "full_path": "diaspora"
+ },
+ "import_status": "none",
+ "archived": false,
+ "avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png",
+ "shared_runners_enabled": true,
+ "forks_count": 0,
+ "star_count": 0,
+ "runners_token": "b8547b1dc37721d05889db52fa2f02",
+ "public_jobs": true,
+ "shared_with_groups": [],
+ "only_allow_merge_if_pipeline_succeeds": false,
+ "only_allow_merge_if_all_discussions_are_resolved": false,
+ "request_access_enabled": false,
+ "merge_method": "merge",
+ "statistics": {
+ "commit_count": 37,
+ "storage_size": 1038090,
+ "repository_size": 1038090,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
+ },
+ {
+ "id": 6,
+ "description": null,
+ "default_branch": "master",
+ "visibility": "private",
+ "ssh_url_to_repo": "git@example.com:brightbox/puppet.git",
+ "http_url_to_repo": "http://example.com/brightbox/puppet.git",
+ "web_url": "http://example.com/brightbox/puppet",
+ "readme_url": "http://example.com/brightbox/puppet/blob/master/README.md",
+ "tag_list": [
+ "example",
+ "puppet"
+ ],
+ "owner": {
+ "id": 4,
+ "name": "Brightbox",
+ "created_at": "2013-09-30T13:46:02Z"
+ },
+ "name": "Puppet",
+ "name_with_namespace": "Brightbox / Puppet",
+ "path": "puppet",
+ "path_with_namespace": "brightbox/puppet",
+ "issues_enabled": true,
+ "open_issues_count": 1,
+ "merge_requests_enabled": true,
+ "jobs_enabled": true,
+ "wiki_enabled": true,
+ "snippets_enabled": false,
+ "resolve_outdated_diff_discussions": false,
+ "container_registry_enabled": false,
+ "created_at": "2013-09-30T13:46:02Z",
+ "last_activity_at": "2013-09-30T13:46:02Z",
+ "creator_id": 3,
+ "namespace": {
+ "id": 4,
+ "name": "Brightbox",
+ "path": "brightbox",
+ "kind": "group",
+ "full_path": "brightbox"
+ },
+ "import_status": "none",
+ "import_error": null,
+ "permissions": {
+ "project_access": {
+ "access_level": 10,
+ "notification_level": 3
+ },
+ "group_access": {
+ "access_level": 50,
+ "notification_level": 3
+ }
+ },
+ "archived": false,
+ "avatar_url": null,
+ "shared_runners_enabled": true,
+ "forks_count": 0,
+ "star_count": 0,
+ "runners_token": "b8547b1dc37721d05889db52fa2f02",
+ "public_jobs": true,
+ "shared_with_groups": [],
+ "only_allow_merge_if_pipeline_succeeds": false,
+ "only_allow_merge_if_all_discussions_are_resolved": false,
+ "request_access_enabled": false,
+ "merge_method": "merge",
+ "statistics": {
+ "commit_count": 12,
+ "storage_size": 2066080,
+ "repository_size": 2066080,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
+ }
+]
+```
+
## Get single project
Get a specific project. This endpoint can be accessed without authentication if
@@ -1155,6 +1343,51 @@ Example response:
}
```
+## List Starrers of a project
+
+List the users who starred the specified project.
+
+```
+GET /projects/:id/starrers
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `search` | string | no | Search for specific users. |
+
+```bash
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/starrers"
+```
+
+Example responses:
+
+```json
+[
+ {
+ "starred_since": "2019-01-28T14:47:30.642Z",
+ "user":
+ {
+ "id": 1,
+ "username": "jane_smith",
+ "name": "Jane Smith",
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg",
+ "web_url": "http://localhost:3000/jane_smith"
+ }
+ },
+ "starred_since": "2018-01-02T11:40:26.570Z",
+ "user":
+ {
+ "id": 2,
+ "username": "janine_smith",
+ "name": "Janine Smith",
+ "state": "blocked",
+ "avatar_url": "http://gravatar.com/../e32131cd8.jpeg",
+ "web_url": "http://localhost:3000/janine_smith"
+ }
+]
+```
+
## Languages
Get languages used in a project with percentage value.
diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md
index 06710065dfd..e5ccc8ee758 100644
--- a/doc/user/profile/index.md
+++ b/doc/user/profile/index.md
@@ -27,6 +27,7 @@ On your profile page, you will see the following information:
- Groups: [groups](../group/index.md) you're a member of
- Contributed projects: [projects](../project/index.md) you contributed to
- 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)
## Profile settings
@@ -91,6 +92,7 @@ The following information will be hidden from the user profile page (`https://gi
- Groups tab
- Contributed projects tab
- Personal projects tab
+- Starred projects tab
- Snippets tab
To enable private profile:
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 2f5ce3d4003..643b53f5e63 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -77,6 +77,11 @@ module API
expose :last_activity_on, as: :last_activity_at # Back-compat
end
+ class UserStarsProject < Grape::Entity
+ expose :starred_since
+ expose :user, using: Entities::UserBasic
+ end
+
class Identity < Grape::Entity
expose :provider, :extern_uid
end
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 0923d31f5ff..996205d4b7b 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -115,6 +115,22 @@ module API
present_projects load_projects
end
+
+ desc 'Get projects starred by a user' do
+ success Entities::BasicProjectDetails
+ end
+ params do
+ requires :user_id, type: String, desc: 'The ID or username of the user'
+ use :collection_params
+ use :statistics_params
+ end
+ get ":user_id/starred_projects" do
+ user = find_user(params[:user_id])
+ not_found!('User') unless user
+
+ starred_projects = StarredProjectsFinder.new(user, params: project_finder_params, current_user: current_user).execute
+ present_projects starred_projects
+ end
end
resource :projects do
@@ -358,6 +374,19 @@ module API
end
end
+ desc 'Get the users who starred a project' do
+ success Entities::UserBasic
+ end
+ params do
+ optional :search, type: String, desc: 'Return list of users matching the search criteria'
+ use :pagination
+ end
+ get ':id/starrers' do
+ starrers = UsersStarProjectsFinder.new(user_project, params, current_user: current_user).execute
+
+ present paginate(starrers), with: Entities::UserStarsProject
+ end
+
desc 'Get languages in project repository'
get ':id/languages' do
::Projects::RepositoryLanguagesService
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7586fff2e7d..18b4a00bb79 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -7227,12 +7227,18 @@ msgstr ""
msgid "No schedules"
msgstr ""
+msgid "No starrers matched your search"
+msgstr ""
+
msgid "No template"
msgstr ""
msgid "No, directly import the existing email addresses and usernames."
msgstr ""
+msgid "Nobody has starred this repository yet"
+msgstr ""
+
msgid "None"
msgstr ""
@@ -8543,6 +8549,12 @@ msgstr ""
msgid "ProjectOverview|Star"
msgstr ""
+msgid "ProjectOverview|Starrer"
+msgstr ""
+
+msgid "ProjectOverview|Starrers"
+msgstr ""
+
msgid "ProjectOverview|Unstar"
msgstr ""
@@ -10268,6 +10280,9 @@ msgstr ""
msgid "SortOptions|Oldest sign in"
msgstr ""
+msgid "SortOptions|Oldest starred"
+msgstr ""
+
msgid "SortOptions|Oldest updated"
msgstr ""
@@ -10283,6 +10298,9 @@ msgstr ""
msgid "SortOptions|Recent sign in"
msgstr ""
+msgid "SortOptions|Recently starred"
+msgstr ""
+
msgid "SortOptions|Sort direction"
msgstr ""
@@ -10376,6 +10394,9 @@ msgstr ""
msgid "StarredProjectsEmptyState|You don't have starred projects yet."
msgstr ""
+msgid "Starrers"
+msgstr ""
+
msgid "Stars"
msgstr ""
@@ -12218,6 +12239,12 @@ msgstr ""
msgid "UserProfile|Snippets in GitLab can either be private, internal, or public."
msgstr ""
+msgid "UserProfile|Star projects to track their progress and show your appreciation."
+msgstr ""
+
+msgid "UserProfile|Starred projects"
+msgstr ""
+
msgid "UserProfile|Subscribe"
msgstr ""
@@ -12230,6 +12257,9 @@ msgstr ""
msgid "UserProfile|This user hasn't contributed to any projects"
msgstr ""
+msgid "UserProfile|This user hasn't starred any projects"
+msgstr ""
+
msgid "UserProfile|View all"
msgstr ""
diff --git a/spec/controllers/projects/starrers_controller_spec.rb b/spec/controllers/projects/starrers_controller_spec.rb
new file mode 100644
index 00000000000..59d258e99ce
--- /dev/null
+++ b/spec/controllers/projects/starrers_controller_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::StarrersController do
+ let(:user) { create(:user) }
+ let(:private_user) { create(:user, private_profile: true) }
+ let(:admin) { create(:user, admin: true) }
+ let(:project) { create(:project, :public, :repository) }
+
+ before do
+ user.toggle_star(project)
+ private_user.toggle_star(project)
+ end
+
+ describe 'GET index' do
+ def get_starrers
+ get :index,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project
+ }
+ end
+
+ context 'when project is public' do
+ before do
+ project.update_attribute(:visibility_level, Project::PUBLIC)
+ end
+
+ context 'when no user is logged in' do
+ before do
+ get_starrers
+ end
+
+ it 'only public starrers are visible' do
+ user_ids = assigns[:starrers].map { |s| s['user_id'] }
+ expect(user_ids).to include(user.id)
+ expect(user_ids).not_to include(private_user.id)
+ end
+
+ it 'public/private starrers counts are correct' do
+ expect(assigns[:public_count]).to eq(1)
+ expect(assigns[:private_count]).to eq(1)
+ end
+ end
+
+ context 'when private user is logged in' do
+ before do
+ sign_in(private_user)
+
+ get_starrers
+ end
+
+ it 'their star is also visible' do
+ user_ids = assigns[:starrers].map { |s| s['user_id'] }
+ expect(user_ids).to include(user.id, private_user.id)
+ end
+
+ it 'public/private starrers counts are correct' do
+ expect(assigns[:public_count]).to eq(1)
+ expect(assigns[:private_count]).to eq(1)
+ end
+ end
+
+ context 'when admin is logged in' do
+ before do
+ sign_in(admin)
+
+ get_starrers
+ end
+
+ it 'all stars are visible' do
+ user_ids = assigns[:starrers].map { |s| s['user_id'] }
+ expect(user_ids).to include(user.id, private_user.id)
+ end
+
+ it 'public/private starrers counts are correct' do
+ expect(assigns[:public_count]).to eq(1)
+ expect(assigns[:private_count]).to eq(1)
+ end
+ end
+ end
+
+ context 'when project is private' do
+ before do
+ project.update(visibility_level: Project::PRIVATE)
+ end
+
+ it 'starrers are not visible for non logged in users' do
+ get_starrers
+
+ expect(assigns[:starrers]).to be_blank
+ end
+
+ context 'when user is logged in' do
+ before do
+ sign_in(project.creator)
+ end
+
+ it 'only public starrers are visible' do
+ get_starrers
+
+ user_ids = assigns[:starrers].map { |s| s['user_id'] }
+ expect(user_ids).to include(user.id)
+ expect(user_ids).not_to include(private_user.id)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb
index d2d084c9174..9c17aac09e8 100644
--- a/spec/features/groups/members/search_members_spec.rb
+++ b/spec/features/groups/members/search_members_spec.rb
@@ -19,9 +19,9 @@ describe 'Search group member' do
end
it 'renders member users' do
- page.within '.member-search-form' do
+ page.within '.user-search-form' do
fill_in 'search', with: member.name
- find('.member-search-btn').click
+ find('.user-search-btn').click
end
group_members_list = find(".card .content-list")
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index 11770e6ac2a..48b0136227e 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -19,7 +19,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by access level ascending' do
@@ -27,7 +27,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
end
it 'sorts by access level descending' do
@@ -35,7 +35,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
end
it 'sorts by last joined' do
@@ -43,7 +43,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
end
it 'sorts by oldest joined' do
@@ -51,7 +51,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
end
it 'sorts by name ascending' do
@@ -59,7 +59,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by name descending' do
@@ -67,7 +67,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
@@ -75,7 +75,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
@@ -83,7 +83,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
end
def visit_members_list(sort:)
diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb
index 7b1fded1834..6e8d1a945e1 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -52,18 +52,18 @@ describe 'Projects > Members > Groups with access list', :js do
context 'search in existing members (yes, this filters the groups list as well)' do
it 'finds no results' do
- page.within '.member-search-form' do
+ page.within '.user-search-form' do
fill_in 'search', with: 'testing 123'
- find('.member-search-btn').click
+ find('.user-search-btn').click
end
expect(page).not_to have_selector('.group_member')
end
it 'finds results' do
- page.within '.member-search-form' do
+ page.within '.user-search-form' do
fill_in 'search', with: group.name
- find('.member-search-btn').click
+ find('.user-search-btn').click
end
expect(page).to have_selector('.group_member', count: 1)
diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb
index 332f07614da..88240fbbedc 100644
--- a/spec/features/projects/members/sorting_spec.rb
+++ b/spec/features/projects/members/sorting_spec.rb
@@ -18,7 +18,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by access level ascending' do
@@ -26,7 +26,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
end
it 'sorts by access level descending' do
@@ -34,7 +34,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
end
it 'sorts by last joined' do
@@ -42,7 +42,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
end
it 'sorts by oldest joined' do
@@ -50,7 +50,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
end
it 'sorts by name ascending' do
@@ -58,7 +58,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by name descending' do
@@ -66,7 +66,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
@@ -74,7 +74,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
@@ -82,7 +82,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
+ expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
end
def visit_members_list(sort:)
diff --git a/spec/finders/starred_projects_finder_spec.rb b/spec/finders/starred_projects_finder_spec.rb
new file mode 100644
index 00000000000..7aa8251c3ab
--- /dev/null
+++ b/spec/finders/starred_projects_finder_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe StarredProjectsFinder do
+ let(:project1) { create(:project, :public, :empty_repo) }
+ let(:project2) { create(:project, :public, :empty_repo) }
+ let(:other_project) { create(:project, :public, :empty_repo) }
+
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+
+ before do
+ user.toggle_star(project1)
+ user.toggle_star(project2)
+ end
+
+ describe '#execute' do
+ let(:finder) { described_class.new(user, params: {}, current_user: current_user) }
+
+ subject { finder.execute }
+
+ describe 'as same user' do
+ let(:current_user) { user }
+
+ it { is_expected.to contain_exactly(project1, project2) }
+ end
+
+ describe 'as other user' do
+ let(:current_user) { other_user }
+
+ it { is_expected.to contain_exactly(project1, project2) }
+ end
+
+ describe 'as no user' do
+ let(:current_user) { nil }
+
+ it { is_expected.to contain_exactly(project1, project2) }
+ end
+ end
+end
diff --git a/spec/finders/users_star_projects_finder_spec.rb b/spec/finders/users_star_projects_finder_spec.rb
new file mode 100644
index 00000000000..fb1d8088f44
--- /dev/null
+++ b/spec/finders/users_star_projects_finder_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe UsersStarProjectsFinder do
+ let(:project) { create(:project, :public, :empty_repo) }
+
+ let(:user) { create(:user) }
+ let(:private_user) { create(:user, private_profile: true) }
+ let(:other_user) { create(:user) }
+
+ before do
+ user.toggle_star(project)
+ private_user.toggle_star(project)
+ end
+
+ describe '#execute' do
+ let(:finder) { described_class.new(project, {}, current_user: current_user) }
+ let(:public_stars) { user.users_star_projects }
+ let(:private_stars) { private_user.users_star_projects }
+
+ subject { finder.execute }
+
+ describe 'as same user' do
+ let(:current_user) { private_user }
+
+ it { is_expected.to match_array(private_stars + public_stars) }
+ end
+
+ describe 'as other user' do
+ let(:current_user) { other_user }
+
+ it { is_expected.to match_array(public_stars) }
+ end
+
+ describe 'as no user' do
+ let(:current_user) { nil }
+
+ it { is_expected.to match_array(public_stars) }
+ end
+ end
+end
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index f3649495493..a6623bc7941 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -27,7 +27,7 @@ describe UsersHelper do
context 'with public profile' do
it 'includes all the expected tabs' do
- expect(tabs).to include(:activity, :groups, :contributed, :projects, :snippets)
+ expect(tabs).to include(:activity, :groups, :contributed, :projects, :starred, :snippets)
end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 5b3a2412aff..1d7ca85cdd2 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -838,6 +838,28 @@ describe API::Projects do
end
end
+ describe 'GET /users/:user_id/starred_projects/' do
+ before do
+ user3.update(starred_projects: [project, project2, project3])
+ end
+
+ it 'returns error when user not found' do
+ get api('/users/9999/starred_projects/')
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+
+ it 'returns projects filtered by user' do
+ get api("/users/#{user3.id}/starred_projects/", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(project.id, project2.id, project3.id)
+ end
+ end
+
describe 'POST /projects/user/:id' do
it 'creates new project without path but with name and return 201' do
expect { post api("/projects/user/#{user.id}", admin), params: { name: 'Foo Project' } }.to change { Project.count }.by(1)
@@ -2148,6 +2170,85 @@ describe API::Projects do
end
end
+ describe 'GET /projects/:id/starrers' do
+ shared_examples_for 'project starrers response' do
+ it 'returns an array of starrers' do
+ get api("/projects/#{public_project.id}/starrers", current_user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response[0]['starred_since']).to be_present
+ expect(json_response[0]['user']).to be_present
+ end
+
+ it 'returns the proper security headers' do
+ get api('/projects/1/starrers', current_user)
+
+ expect(response).to include_security_headers
+ end
+ end
+
+ let(:public_project) { create(:project, :public) }
+ let(:private_user) { create(:user, private_profile: true) }
+
+ before do
+ user.update(starred_projects: [public_project])
+ private_user.update(starred_projects: [public_project])
+ end
+
+ it 'returns not_found(404) for not existing project' do
+ get api("/projects/9999999999/starrers", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'public project without user' do
+ it_behaves_like 'project starrers response' do
+ let(:current_user) { nil }
+ end
+
+ it 'returns only starrers with a public profile' do
+ get api("/projects/#{public_project.id}/starrers", nil)
+
+ user_ids = json_response.map { |s| s['user']['id'] }
+ expect(user_ids).to include(user.id)
+ expect(user_ids).not_to include(private_user.id)
+ end
+ end
+
+ context 'public project with user with private profile' do
+ it_behaves_like 'project starrers response' do
+ let(:current_user) { private_user }
+ end
+
+ it 'returns current user with a private profile' do
+ get api("/projects/#{public_project.id}/starrers", private_user)
+
+ user_ids = json_response.map { |s| s['user']['id'] }
+ expect(user_ids).to include(user.id, private_user.id)
+ end
+ end
+
+ context 'private project' do
+ context 'with unauthorized user' do
+ it 'returns not_found for existing but unauthorized project' do
+ get api("/projects/#{project3.id}/starrers", user3)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'without user' do
+ it 'returns not_found for existing but unauthorized project' do
+ get api("/projects/#{project3.id}/starrers", nil)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
describe 'GET /projects/:id/languages' do
context 'with an authorized user' do
it_behaves_like 'languages and percentages JSON response' do