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,6 +57,22 @@ class UsersController < ApplicationController def projects load_projects + 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]) @@ -64,20 +80,7 @@ class UsersController < ApplicationController 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 - - def contributed - load_contributed_projects - - 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 %{project_name}").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: " "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: " "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