Commit 3ad34c3a authored by Stan Hu's avatar Stan Hu

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
parents 4985f6e2 d4078b53
...@@ -143,7 +143,7 @@ export default class UserTabs { ...@@ -143,7 +143,7 @@ export default class UserTabs {
this.loadOverviewTab(); this.loadOverviewTab();
} }
const loadableActions = ['groups', 'contributed', 'projects', 'snippets']; const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets'];
if (loadableActions.indexOf(action) > -1) { if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint); this.loadTab(action, endpoint);
} }
......
...@@ -18,7 +18,7 @@ export default class Star { ...@@ -18,7 +18,7 @@ export default class Star {
const isStarred = $starSpan.hasClass('starred'); const isStarred = $starSpan.hasClass('starred');
$this $this
.parent() .parent()
.find('.star-count') .find('.count')
.text(data.star_count); .text(data.star_count);
if (isStarred) { if (isStarred) {
......
...@@ -9,10 +9,6 @@ ...@@ -9,10 +9,6 @@
} }
} }
.member-sort-dropdown {
margin-left: $gl-padding-8;
}
.member { .member {
&.is-overridden { &.is-overridden {
.btn-ldap-override { .btn-ldap-override {
...@@ -62,36 +58,9 @@ ...@@ -62,36 +58,9 @@
} }
} }
.member-search-form { .member-access-text {
position: relative; margin-left: auto;
line-height: 43px;
@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-search-btn { .member-search-btn {
...@@ -177,7 +146,7 @@ ...@@ -177,7 +146,7 @@
padding-bottom: 1px; padding-bottom: 1px;
} }
.flex-project-members-form { .flex-users-form {
flex-wrap: nowrap; flex-wrap: nowrap;
white-space: nowrap; white-space: nowrap;
margin-left: auto; margin-left: auto;
......
.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;
}
}
# 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
...@@ -17,7 +17,7 @@ class UsersController < ApplicationController ...@@ -17,7 +17,7 @@ class UsersController < ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists] before_action :user, except: [:exists]
before_action :authorize_read_user_profile!, 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 def show
respond_to do |format| respond_to do |format|
...@@ -57,27 +57,30 @@ class UsersController < ApplicationController ...@@ -57,27 +57,30 @@ class UsersController < ApplicationController
def projects def projects
load_projects load_projects
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination]) present_projects(@projects)
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
end end
def contributed def contributed
load_contributed_projects 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| respond_to do |format|
format.html { render 'show' } format.html { render 'show' }
format.json do format.json do
render json: { pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
html: view_to_html_string("shared/projects/_list", projects: @contributed_projects)
}
end end
end end
end end
...@@ -120,6 +123,10 @@ class UsersController < ApplicationController ...@@ -120,6 +123,10 @@ class UsersController < ApplicationController
ContributedProjectsFinder.new(user).execute(current_user) ContributedProjectsFinder.new(user).execute(current_user)
end end
def starred_projects
StarredProjectsFinder.new(user, current_user: current_user).execute
end
def contributions_calendar def contributions_calendar
@contributions_calendar ||= Gitlab::ContributionsCalendar.new(user, current_user) @contributions_calendar ||= Gitlab::ContributionsCalendar.new(user, current_user)
end end
...@@ -145,6 +152,12 @@ class UsersController < ApplicationController ...@@ -145,6 +152,12 @@ class UsersController < ApplicationController
prepare_projects_for_rendering(@contributed_projects) prepare_projects_for_rendering(@contributed_projects)
end end
def load_starred_projects
@starred_projects = starred_projects
prepare_projects_for_rendering(@starred_projects)
end
def load_groups def load_groups
@groups = JoinedGroupsFinder.new(user).execute(current_user) @groups = JoinedGroupsFinder.new(user).execute(current_user)
......
# 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
# 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
...@@ -601,6 +601,11 @@ module ProjectsHelper ...@@ -601,6 +601,11 @@ module ProjectsHelper
end end
end end
def filter_starrer_path(options = {})
options = params.slice(:sort).merge(options).permit!
"#{request.path}?#{options.to_param}"
end
def sidebar_projects_paths def sidebar_projects_paths
%w[ %w[
projects#show projects#show
......
...@@ -167,6 +167,15 @@ module SortingHelper ...@@ -167,6 +167,15 @@ module SortingHelper
} }
end 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) def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : '' link_to item, path, class: sorted_by == item ? 'is-active' : ''
end end
...@@ -327,6 +336,10 @@ module SortingHelper ...@@ -327,6 +336,10 @@ module SortingHelper
s_('SortOptions|Oldest sign in') s_('SortOptions|Oldest sign in')
end end
def sort_title_oldest_starred
s_('SortOptions|Oldest starred')
end
def sort_title_oldest_updated def sort_title_oldest_updated
s_('SortOptions|Oldest updated') s_('SortOptions|Oldest updated')
end end
...@@ -347,6 +360,10 @@ module SortingHelper ...@@ -347,6 +360,10 @@ module SortingHelper
s_('SortOptions|Recent sign in') s_('SortOptions|Recent sign in')
end end
def sort_title_recently_starred
s_('SortOptions|Recently starred')
end
def sort_title_recently_updated def sort_title_recently_updated
s_('SortOptions|Last updated') s_('SortOptions|Last updated')
end end
......
...@@ -89,7 +89,7 @@ module UsersHelper ...@@ -89,7 +89,7 @@ module UsersHelper
tabs = [] tabs = []
if can?(current_user, :read_user_profile, @user) if can?(current_user, :read_user_profile, @user)
tabs += [:overview, :activity, :groups, :contributed, :projects, :snippets] tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets]
end end
tabs tabs
......
...@@ -282,6 +282,17 @@ class User < ApplicationRecord ...@@ -282,6 +282,17 @@ class User < ApplicationRecord
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) } scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } 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. # Limits the users to those that have TODOs, optionally in the given state.
# #
......
# frozen_string_literal: true # frozen_string_literal: true
class UsersStarProject < ApplicationRecord class UsersStarProject < ApplicationRecord
include Sortable
belongs_to :project, counter_cache: :star_count, touch: true belongs_to :project, counter_cache: :star_count, touch: true
belongs_to :user belongs_to :user
validates :user, presence: true validates :user, presence: true
validates :user_id, uniqueness: { scope: [:project_id] } validates :user_id, uniqueness: { scope: [:project_id] }
validates :project, presence: true 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 end
...@@ -25,11 +25,11 @@ ...@@ -25,11 +25,11 @@
Members with access to Members with access to
%strong= @group.name %strong= @group.name
%span.badge.badge-pill= @members.total_count %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 .form-group
.position-relative.append-right-8 .position-relative.append-right-8
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } = 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") = icon("search")
- if can_manage_members - if can_manage_members
= render 'shared/members/filter_2fa_dropdown' = render 'shared/members/filter_2fa_dropdown'
......
...@@ -8,7 +8,8 @@ ...@@ -8,7 +8,8 @@
= sprite_icon('star-o', { css_class: 'icon' }) = sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star') %span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center %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 - else
.count-badge.d-inline-flex.align-item-stretch.append-right-8 .count-badge.d-inline-flex.align-item-stretch.append-right-8
...@@ -16,4 +17,5 @@ ...@@ -16,4 +17,5 @@
= sprite_icon('star-o', { css_class: 'icon' }) = sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star') %span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center %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
...@@ -6,11 +6,11 @@ ...@@ -6,11 +6,11 @@
%span.flex-project-title %span.flex-project-title
= _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) } = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) }
%span.badge.badge-pill= members.total_count %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 .form-group
.position-relative .position-relative
= search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false } = 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") = icon("search")
= render 'shared/members/sort_dropdown' = render 'shared/members/sort_dropdown'
%ul.content-list.members-list.qa-members-list %ul.content-list.members-list.qa-members-list
......
- 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)
- 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')
.dropdown.inline.member-sort-dropdown .dropdown.inline.user-sort-dropdown
= dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }) = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header %li.dropdown-header
......
...@@ -17,15 +17,20 @@ ...@@ -17,15 +17,20 @@
- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg' - 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_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') - 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_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_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_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') - 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.') - explore_page_empty_message = s_('UserProfile|Explore public groups to find projects to contribute to.')
- primary_button_label = _('New project') - new_project_button_label = _('New project')
- primary_button_link = new_project_path - new_project_button_link = new_project_path
- secondary_button_label = _('Explore groups') - explore_projects_button_label = _('Explore projects')
- secondary_button_link = explore_groups_path - explore_projects_button_link = explore_projects_path
- explore_groups_button_label = _('Explore groups')
- explore_groups_button_link = explore_groups_path
.js-projects-list-holder .js-projects-list-holder
- if any_projects?(projects) - if any_projects?(projects)
...@@ -48,15 +53,21 @@ ...@@ -48,15 +53,21 @@
- if @contributed_projects - if @contributed_projects
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path, = 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, current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
primary_button_label: primary_button_label, primary_button_label: new_project_button_label,
primary_button_link: primary_button_link, primary_button_link: new_project_button_link,
secondary_button_label: secondary_button_label, secondary_button_label: explore_groups_button_label,
secondary_button_link: secondary_button_link, secondary_button_link: explore_groups_button_link,
visitor_empty_message: contributed_projects_visitor_empty_message } 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 - else
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path, = 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_header: own_projects_current_user_empty_message_header,
current_user_empty_message_description: own_projects_current_user_empty_message_description, current_user_empty_message_description: own_projects_current_user_empty_message_description,
primary_button_label: primary_button_label, primary_button_label: new_project_button_label,
primary_button_link: primary_button_link, primary_button_link: new_project_button_link,
visitor_empty_message: defined?(explore_page) && explore_page ? explore_page_empty_message : own_projects_visitor_empty_message } visitor_empty_message: defined?(explore_page) && explore_page ? explore_page_empty_message : own_projects_visitor_empty_message }
...@@ -63,7 +63,9 @@ ...@@ -63,7 +63,9 @@
- if project.archived - if project.archived
%span.d-flex.icon-wrapper.badge.badge-warning archived %span.d-flex.icon-wrapper.badge.badge-warning archived
- if stars - 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') = sprite_icon('star', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.star_count) = number_with_delimiter(project.star_count)
- if forks - if forks
......
...@@ -111,6 +111,10 @@ ...@@ -111,6 +111,10 @@
%li.js-projects-tab %li.js-projects-tab
= link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
= s_('UserProfile|Personal projects') = 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) - if profile_tab?(:snippets)
%li.js-snippets-tab %li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
...@@ -142,6 +146,10 @@ ...@@ -142,6 +146,10 @@
#projects.tab-pane #projects.tab-pane
-# This tab is always loaded via AJAX -# 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) - if profile_tab?(:snippets)
#snippets.tab-pane #snippets.tab-pane
-# This tab is always loaded via AJAX -# This tab is always loaded via AJAX
......
---
title: Make starred projects and starrers of a project publicly visible
merge_request: 24690
author:
type: added
...@@ -170,7 +170,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -170,7 +170,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :recent get :recent
end end
end end
resources :releases, only: [:index] resources :releases, only: [:index]
resources :starrers, only: [:index]
resources :forks, only: [:index, :new, :create] resources :forks, only: [:index, :new, :create]
resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ }
......
...@@ -59,6 +59,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d ...@@ -59,6 +59,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :groups get :groups
get :projects get :projects
get :contributed, as: :contributed_projects get :contributed, as: :contributed_projects
get :starred, as: :starred_projects
get :snippets get :snippets
get :exists get :exists
get :activity get :activity
......
...@@ -465,6 +465,194 @@ GET /users/:user_id/projects ...@@ -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 single project
Get a specific project. This endpoint can be accessed without authentication if Get a specific project. This endpoint can be accessed without authentication if
...@@ -1155,6 +1343,51 @@ Example response: ...@@ -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 ## Languages
Get languages used in a project with percentage value. Get languages used in a project with percentage value.
......
...@@ -27,6 +27,7 @@ On your profile page, you will see the following information: ...@@ -27,6 +27,7 @@ On your profile page, you will see the following information:
- Groups: [groups](../group/index.md) you're a member of - Groups: [groups](../group/index.md) you're a member of
- Contributed projects: [projects](../project/index.md) you contributed to - Contributed projects: [projects](../project/index.md) you contributed to
- Personal projects: your personal projects (respecting the project's visibility level) - Personal projects: your personal projects (respecting the project's visibility level)
- Starred projects: projects you starred
- Snippets: your personal code [snippets](../snippets.md#personal-snippets) - Snippets: your personal code [snippets](../snippets.md#personal-snippets)
## Profile settings ## Profile settings
...@@ -91,6 +92,7 @@ The following information will be hidden from the user profile page (`https://gi ...@@ -91,6 +92,7 @@ The following information will be hidden from the user profile page (`https://gi
- Groups tab - Groups tab
- Contributed projects tab - Contributed projects tab
- Personal projects tab - Personal projects tab
- Starred projects tab
- Snippets tab - Snippets tab
To enable private profile: To enable private profile:
......
...@@ -77,6 +77,11 @@ module API ...@@ -77,6 +77,11 @@ module API
expose :last_activity_on, as: :last_activity_at # Back-compat expose :last_activity_on, as: :last_activity_at # Back-compat
end end
class UserStarsProject < Grape::Entity
expose :starred_since
expose :user, using: Entities::UserBasic
end
class Identity < Grape::Entity class Identity < Grape::Entity
expose :provider, :extern_uid expose :provider, :extern_uid
end end
......
...@@ -115,6 +115,22 @@ module API ...@@ -115,6 +115,22 @@ module API
present_projects load_projects present_projects load_projects
end 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 end
resource :projects do resource :projects do
...@@ -358,6 +374,19 @@ module API ...@@ -358,6 +374,19 @@ module API
end end
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' desc 'Get languages in project repository'
get ':id/languages' do get ':id/languages' do
::Projects::RepositoryLanguagesService ::Projects::RepositoryLanguagesService
......
...@@ -7227,12 +7227,18 @@ msgstr "" ...@@ -7227,12 +7227,18 @@ msgstr ""
msgid "No schedules" msgid "No schedules"
msgstr "" msgstr ""
msgid "No starrers matched your search"
msgstr ""
msgid "No template" msgid "No template"
msgstr "" msgstr ""
msgid "No, directly import the existing email addresses and usernames." msgid "No, directly import the existing email addresses and usernames."
msgstr "" msgstr ""
msgid "Nobody has starred this repository yet"
msgstr ""
msgid "None" msgid "None"
msgstr "" msgstr ""
...@@ -8543,6 +8549,12 @@ msgstr "" ...@@ -8543,6 +8549,12 @@ msgstr ""
msgid "ProjectOverview|Star" msgid "ProjectOverview|Star"
msgstr "" msgstr ""
msgid "ProjectOverview|Starrer"
msgstr ""
msgid "ProjectOverview|Starrers"
msgstr ""
msgid "ProjectOverview|Unstar" msgid "ProjectOverview|Unstar"
msgstr "" msgstr ""
...@@ -10268,6 +10280,9 @@ msgstr "" ...@@ -10268,6 +10280,9 @@ msgstr ""
msgid "SortOptions|Oldest sign in" msgid "SortOptions|Oldest sign in"
msgstr "" msgstr ""
msgid "SortOptions|Oldest starred"
msgstr ""
msgid "SortOptions|Oldest updated" msgid "SortOptions|Oldest updated"
msgstr "" msgstr ""
...@@ -10283,6 +10298,9 @@ msgstr "" ...@@ -10283,6 +10298,9 @@ msgstr ""
msgid "SortOptions|Recent sign in" msgid "SortOptions|Recent sign in"
msgstr "" msgstr ""
msgid "SortOptions|Recently starred"
msgstr ""
msgid "SortOptions|Sort direction" msgid "SortOptions|Sort direction"
msgstr "" msgstr ""
...@@ -10376,6 +10394,9 @@ msgstr "" ...@@ -10376,6 +10394,9 @@ msgstr ""
msgid "StarredProjectsEmptyState|You don't have starred projects yet." msgid "StarredProjectsEmptyState|You don't have starred projects yet."
msgstr "" msgstr ""
msgid "Starrers"
msgstr ""
msgid "Stars" msgid "Stars"
msgstr "" msgstr ""
...@@ -12218,6 +12239,12 @@ msgstr "" ...@@ -12218,6 +12239,12 @@ msgstr ""
msgid "UserProfile|Snippets in GitLab can either be private, internal, or public." msgid "UserProfile|Snippets in GitLab can either be private, internal, or public."
msgstr "" msgstr ""
msgid "UserProfile|Star projects to track their progress and show your appreciation."
msgstr ""
msgid "UserProfile|Starred projects"
msgstr ""
msgid "UserProfile|Subscribe" msgid "UserProfile|Subscribe"
msgstr "" msgstr ""
...@@ -12230,6 +12257,9 @@ msgstr "" ...@@ -12230,6 +12257,9 @@ msgstr ""
msgid "UserProfile|This user hasn't contributed to any projects" msgid "UserProfile|This user hasn't contributed to any projects"
msgstr "" msgstr ""
msgid "UserProfile|This user hasn't starred any projects"
msgstr ""
msgid "UserProfile|View all" msgid "UserProfile|View all"
msgstr "" msgstr ""
......
# 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
...@@ -19,9 +19,9 @@ describe 'Search group member' do ...@@ -19,9 +19,9 @@ describe 'Search group member' do
end end
it 'renders member users' do it 'renders member users' do
page.within '.member-search-form' do page.within '.user-search-form' do
fill_in 'search', with: member.name fill_in 'search', with: member.name
find('.member-search-btn').click find('.user-search-btn').click
end end
group_members_list = find(".card .content-list") group_members_list = find(".card .content-list")
......
...@@ -19,7 +19,7 @@ describe 'Groups > Members > Sort members' do ...@@ -19,7 +19,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name) expect(first_member).to include(owner.name)
expect(second_member).to include(developer.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 end
it 'sorts by access level ascending' do it 'sorts by access level ascending' do
...@@ -27,7 +27,7 @@ describe 'Groups > Members > Sort members' do ...@@ -27,7 +27,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(owner.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 end
it 'sorts by access level descending' do it 'sorts by access level descending' do
...@@ -35,7 +35,7 @@ describe 'Groups > Members > Sort members' do ...@@ -35,7 +35,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name) expect(first_member).to include(owner.name)
expect(second_member).to include(developer.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 end
it 'sorts by last joined' do it 'sorts by last joined' do
...@@ -43,7 +43,7 @@ describe 'Groups > Members > Sort members' do ...@@ -43,7 +43,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(owner.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 end
it 'sorts by oldest joined' do it 'sorts by oldest joined' do
...@@ -51,7 +51,7 @@ describe 'Groups > Members > Sort members' do ...@@ -51,7 +51,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name) expect(first_member).to include(owner.name)
expect(second_member).to include(developer.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 end
it 'sorts by name ascending' do it 'sorts by name ascending' do
...@@ -59,7 +59,7 @@ describe 'Groups > Members > Sort members' do ...@@ -59,7 +59,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name) expect(first_member).to include(owner.name)
expect(second_member).to include(developer.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 end
it 'sorts by name descending' do it 'sorts by name descending' do
...@@ -67,7 +67,7 @@ describe 'Groups > Members > Sort members' do ...@@ -67,7 +67,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(owner.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 end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
...@@ -75,7 +75,7 @@ describe 'Groups > Members > Sort members' do ...@@ -75,7 +75,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name) expect(first_member).to include(owner.name)
expect(second_member).to include(developer.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 end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
...@@ -83,7 +83,7 @@ describe 'Groups > Members > Sort members' do ...@@ -83,7 +83,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(owner.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 end
def visit_members_list(sort:) def visit_members_list(sort:)
......
...@@ -52,18 +52,18 @@ describe 'Projects > Members > Groups with access list', :js do ...@@ -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 context 'search in existing members (yes, this filters the groups list as well)' do
it 'finds no results' do it 'finds no results' do
page.within '.member-search-form' do page.within '.user-search-form' do
fill_in 'search', with: 'testing 123' fill_in 'search', with: 'testing 123'
find('.member-search-btn').click find('.user-search-btn').click
end end
expect(page).not_to have_selector('.group_member') expect(page).not_to have_selector('.group_member')
end end
it 'finds results' do it 'finds results' do
page.within '.member-search-form' do page.within '.user-search-form' do
fill_in 'search', with: group.name fill_in 'search', with: group.name
find('.member-search-btn').click find('.user-search-btn').click
end end
expect(page).to have_selector('.group_member', count: 1) expect(page).to have_selector('.group_member', count: 1)
......
...@@ -18,7 +18,7 @@ describe 'Projects > Members > Sorting' do ...@@ -18,7 +18,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name) expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.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 end
it 'sorts by access level ascending' do it 'sorts by access level ascending' do
...@@ -26,7 +26,7 @@ describe 'Projects > Members > Sorting' do ...@@ -26,7 +26,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.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 end
it 'sorts by access level descending' do it 'sorts by access level descending' do
...@@ -34,7 +34,7 @@ describe 'Projects > Members > Sorting' do ...@@ -34,7 +34,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name) expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.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 end
it 'sorts by last joined' do it 'sorts by last joined' do
...@@ -42,7 +42,7 @@ describe 'Projects > Members > Sorting' do ...@@ -42,7 +42,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name) expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.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 end
it 'sorts by oldest joined' do it 'sorts by oldest joined' do
...@@ -50,7 +50,7 @@ describe 'Projects > Members > Sorting' do ...@@ -50,7 +50,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.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 end
it 'sorts by name ascending' do it 'sorts by name ascending' do
...@@ -58,7 +58,7 @@ describe 'Projects > Members > Sorting' do ...@@ -58,7 +58,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name) expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.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 end
it 'sorts by name descending' do it 'sorts by name descending' do
...@@ -66,7 +66,7 @@ describe 'Projects > Members > Sorting' do ...@@ -66,7 +66,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.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 end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
...@@ -74,7 +74,7 @@ describe 'Projects > Members > Sorting' do ...@@ -74,7 +74,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name) expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.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 end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
...@@ -82,7 +82,7 @@ describe 'Projects > Members > Sorting' do ...@@ -82,7 +82,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name) expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.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 end
def visit_members_list(sort:) def visit_members_list(sort:)
......
# 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
# 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
...@@ -27,7 +27,7 @@ describe UsersHelper do ...@@ -27,7 +27,7 @@ describe UsersHelper do
context 'with public profile' do context 'with public profile' do
it 'includes all the expected tabs' 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
end end
......
...@@ -838,6 +838,28 @@ describe API::Projects do ...@@ -838,6 +838,28 @@ describe API::Projects do
end end
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 describe 'POST /projects/user/:id' do
it 'creates new project without path but with name and return 201' 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) 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 ...@@ -2148,6 +2170,85 @@ describe API::Projects do
end end
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 describe 'GET /projects/:id/languages' do
context 'with an authorized user' do context 'with an authorized user' do
it_behaves_like 'languages and percentages JSON response' do it_behaves_like 'languages and percentages JSON response' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment