Commit 745b7d7c authored by Jonas Waelter's avatar Jonas Waelter Committed by Jonas Wälter

Add 'Explore topics' page

Changelog: added
parent 6f05c324
......@@ -69,6 +69,12 @@ export const GO_TO_YOUR_SNIPPETS = {
defaultKeys: ['shift+s'],
};
export const GO_TO_YOUR_TOPICS = {
id: 'globalShortcuts.goToYourTopics',
description: __('Go to your topics'),
defaultKeys: ['shift+o'],
};
export const START_SEARCH = {
id: 'globalShortcuts.startSearch',
description: __('Start search'),
......@@ -498,6 +504,7 @@ export const GLOBAL_SHORTCUTS_GROUP = {
GO_TO_ACTIVITY_FEED,
GO_TO_MILESTONE_LIST,
GO_TO_YOUR_SNIPPETS,
GO_TO_YOUR_TOPICS,
START_SEARCH,
FOCUS_FILTER_BAR,
GO_TO_YOUR_ISSUES,
......
......@@ -23,6 +23,7 @@ import {
GO_TO_YOUR_GROUPS,
GO_TO_MILESTONE_LIST,
GO_TO_YOUR_SNIPPETS,
GO_TO_YOUR_TOPICS,
GO_TO_PROJECT_FIND_FILE,
} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
......@@ -106,6 +107,9 @@ export default class Shortcuts {
Mousetrap.bind(keysFor(GO_TO_YOUR_SNIPPETS), () =>
findAndFollowLink('.dashboard-shortcuts-snippets'),
);
Mousetrap.bind(keysFor(GO_TO_YOUR_TOPICS), () =>
findAndFollowLink('.dashboard-shortcuts-topics'),
);
Mousetrap.bind(keysFor(TOGGLE_MARKDOWN_PREVIEW), Shortcuts.toggleMarkdownPreview);
......
# frozen_string_literal: true
class Dashboard::TopicsController < Dashboard::ApplicationController
feature_category :projects
def index
@topics = Projects::TopicsFinder.new(current_user: current_user, params: finder_params).execute.page(params[:page])
end
def sort
@sort ||= params[:sort] || 'popularity_desc'
end
private
def finder_params
params.permit(:name).merge(sort: sort, all_available: false)
end
end
# frozen_string_literal: true
class Explore::TopicsController < Explore::ApplicationController
feature_category :projects
def index
@topics = Projects::TopicsFinder.new(current_user: current_user, params: finder_params).execute.page(params[:page])
end
def sort
@sort ||= params[:sort] || 'popularity_desc'
end
private
def finder_params
params.permit(:name).merge(sort: sort, all_available: true)
end
end
# frozen_string_literal: true
# Projects::TopicsFinder
#
# Used to filter Topics (ActsAsTaggableOn::Tag) by a set of params
#
# Arguments:
# current_user - which user is requesting groups
# params:
# all_available: boolean (defaults to true)
# name: string
# sort: string
module Projects
class TopicsFinder
attr_reader :current_user, :params
def initialize(current_user: nil, params: {})
@current_user = current_user
@params = params
end
def execute
projects_relation.tag_counts_on(:topics, options)
end
private
def projects_relation
if current_user.nil? || all_available?
Project.public_or_visible_to_user(current_user)
else
current_user.authorized_projects
end
end
def all_available?
params.fetch(:all_available, true)
end
def options
{
conditions: filter_by_name,
order: sort_by_attribute
}
end
def filter_by_name
ActsAsTaggableOn::Tag.arel_table[:name].matches("%#{params[:name]}%") if params[:name].present?
end
def sort_by_attribute
case params[:sort]
when 'name_asc' then 'tags.name asc'
when 'name_desc' then 'tags.name desc'
when 'popularity_desc' then 'count desc'
else
'count desc'
end
end
end
end
......@@ -52,7 +52,7 @@ module DashboardHelper
private
def get_dashboard_nav_links
links = [:projects, :groups, :snippets]
links = [:projects, :groups, :snippets, :topics]
if can?(current_user, :read_cross_project)
links += [:activity, :milestones]
......
......@@ -58,7 +58,7 @@ module ExploreHelper
private
def get_explore_nav_links
[:projects, :groups, :snippets]
[:projects, :groups, :snippets, :topics]
end
def request_path_with_options(options = {})
......
......@@ -82,6 +82,14 @@ module Nav
)
end
if explore_nav_link?(:topics)
builder.add_primary_menu_item_with_shortcut(
active: active_nav_link?(controller: :topics),
href: explore_topics_path,
**topics_menu_item_attrs
)
end
builder.add_secondary_menu_item(
id: 'help',
title: _('Help'),
......@@ -141,6 +149,15 @@ module Nav
)
end
if dashboard_nav_link?(:topics)
builder.add_primary_menu_item_with_shortcut(
active: active_nav_link?(controller: 'dashboard/topics'),
data: { qa_selector: 'topics_link' },
href: dashboard_topics_path,
**topics_menu_item_attrs
)
end
if dashboard_nav_link?(:activity)
builder.add_primary_menu_item_with_shortcut(
id: 'activity',
......@@ -227,6 +244,15 @@ module Nav
}
end
def topics_menu_item_attrs
{
id: 'topics',
title: _('Topics'),
icon: 'label',
shortcut_class: 'dashboard-shortcuts-topics'
}
end
def container_view_props(namespace:, current_item:, submenu:)
{
namespace: namespace,
......
......@@ -177,6 +177,27 @@ module SortingHelper
}
end
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,
sort_value_access_level_desc => sort_title_access_level_desc,
sort_value_last_joined => sort_title_last_joined,
sort_value_name => sort_title_name_asc,
sort_value_name_desc => sort_title_name_desc,
sort_value_oldest_joined => sort_title_oldest_joined,
sort_value_oldest_signin => sort_title_oldest_signin,
sort_value_recently_signin => sort_title_recently_signin
}
end
def topics_sort_options_hash
{
sort_value_name => sort_title_name_asc,
sort_value_name_desc => sort_title_name_desc,
sort_value_most_popular => sort_title_popularity
}
end
def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
......
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Topics')
.top-area
%ul.nav-links.mobile-separator.nav.nav-tabs
= nav_link(page: dashboard_topics_path) do
= link_to dashboard_topics_path, title: _("Your topics") do
Your topics
= nav_link(page: explore_topics_path) do
= link_to explore_topics_path, title: _("Explore topics") do
Explore topics
.nav-controls
= render 'shared/topics/search_form'
= render 'shared/topics/dropdown'
- @hide_top_links = true
- page_title _("Topics")
- header_title _("Topics"), dashboard_topics_path
= render 'dashboard/topics_head'
- if @topics.empty?
= render 'shared/empty_states/topics'
- else
= render partial: 'shared/topics/list'
......@@ -2,5 +2,5 @@
%h2
= _("Explore GitLab")
%p.lead
= _("Discover projects, groups and snippets. Share your projects with others")
= _("Discover projects, groups, snippets and topics. Share your projects with others")
%br
- @hide_top_links = true
- page_title _("Topics")
- header_title _("Topics"), explore_topics_path
- if current_user
= render 'dashboard/topics_head'
- else
= render 'explore/head'
- if @topics.empty?
= render 'shared/empty_states/topics'
- else
= render partial: 'shared/topics/list'
.row.empty-state
.col-12
.svg-content
= image_tag 'illustrations/labels.svg', data: { qa_selector: 'svg_content' }
.text-content.text-center.pt-0
%h4.text-center= s_('TopicsEmptyState|There are no topics to show.')
%p= _("You can apply topics to projects to categorize them.")
- sort_title = topics_sort_options_hash[@sort] || sort_title_name_asc
.dropdown.inline
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
= sort_title
= sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li
- topics_sort_options_hash.each do |value, title|
= sortable_item(title, page_filter_path(sort: value), sort_title)
- remote = local_assigns.fetch(:remote, false)
- if @topics.to_a.empty?
.nothing-here-block= s_("TopicsEmptyState|No topics found")
- else
.row.gl-mt-3
= render partial: 'shared/topics/topic', collection: @topics
= paginate_collection @topics, remote: remote
= form_tag request.path, method: :get, class: "topic-filter-form js-topic-filter-form", id: 'topic-filter-form' do |f|
= search_field_tag :name, params[:name], placeholder: s_('Topics|Search by name'), class: 'topic-filter-form-field form-control js-topics-list-filter qa-topics-filter', spellcheck: false, id: 'topic-filter-form-field'
.col-lg-3.col-md-4.col-sm-12
.gl-card.gl-mb-5
.gl-card-body
.gl-float-left.gl-mr-4
= sprite_icon('label', size: 32)
.topic-info
%h5.gl-m-0
= topic.name
= link_to n_('%d project', '%d projects', topic.count) % topic.count, explore_projects_path(topic: topic.name)
......@@ -12,6 +12,7 @@ resource :dashboard, controller: 'dashboard', only: [] do
resources :groups, only: [:index]
resources :snippets, only: [:index]
resources :topics, only: [:index]
resources :todos, only: [:index, :destroy] do
collection do
......
......@@ -10,6 +10,7 @@ namespace :explore do
resources :groups, only: [:index]
resources :snippets, only: [:index]
resources :topics, only: [:index]
root to: 'projects#index'
end
......
......@@ -11983,7 +11983,7 @@ msgstr ""
msgid "Discover GitLab Geo"
msgstr ""
msgid "Discover projects, groups and snippets. Share your projects with others"
msgid "Discover projects, groups, snippets and topics. Share your projects with others"
msgstr ""
msgid "Discover|Check your application for security vulnerabilities that may lead to unauthorized access, data leaks, and denial of services."
......@@ -13874,6 +13874,9 @@ msgstr ""
msgid "Explore public groups"
msgstr ""
msgid "Explore topics"
msgstr ""
msgid "Export"
msgstr ""
......@@ -15905,6 +15908,9 @@ msgstr ""
msgid "Go to your snippets"
msgstr ""
msgid "Go to your topics"
msgstr ""
msgid "Goal of the changes and what reviewers should be aware of"
msgstr ""
......@@ -35812,9 +35818,21 @@ msgstr ""
msgid "TopNav|Go back"
msgstr ""
msgid "Topics"
msgstr ""
msgid "Topics (optional)"
msgstr ""
msgid "TopicsEmptyState|No topics found"
msgstr ""
msgid "TopicsEmptyState|There are no topics to show."
msgstr ""
msgid "Topics|Search by name"
msgstr ""
msgid "Total"
msgstr ""
......@@ -38840,6 +38858,9 @@ msgstr ""
msgid "You can always edit this later"
msgstr ""
msgid "You can apply topics to projects to categorize them."
msgstr ""
msgid "You can create a new %{link}."
msgstr ""
......@@ -39589,6 +39610,9 @@ msgid_plural "Your subscription will expire in %{remaining_days} days."
msgstr[0] ""
msgstr[1] ""
msgid "Your topics"
msgstr ""
msgid "Your username is %{username}."
msgstr ""
......
......@@ -41,6 +41,10 @@ RSpec.describe 'Dashboard shortcuts', :js do
find('body').send_keys([:shift, 'L'])
check_page_title('Milestones')
find('body').send_keys([:shift, 'O'])
check_page_title('Topics')
end
end
......@@ -64,6 +68,16 @@ RSpec.describe 'Dashboard shortcuts', :js do
find('.nothing-here-block')
expect(page).to have_content('Explore public groups to find projects to contribute to.')
find('body').send_keys([:shift, 'O'])
find('.empty-state')
expect(page).to have_content('There are no topics to show')
end
end
find('.nothing-here-block')
expect(page).to have_content('Explore public groups to find projects to contribute to.')
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Dashboard > Topics' do
describe 'as anonymous user' do
before do
visit dashboard_topics_path
end
it 'is redirected to sign-in page' do
expect(current_path).to eq new_user_session_path
end
end
describe 'as logged-in user' do
let(:user) { create(:user) }
let(:user_project) { create(:project, namespace: user.namespace) }
let(:other_project) { create(:project, :public) }
before do
sign_in(user)
end
context 'when topics exist' do
before do
user_project.update!(topic_list: 'topic1')
other_project.update!(topic_list: 'topic2')
end
it 'renders correct topics' do
visit dashboard_topics_path
expect(current_path).to eq dashboard_topics_path
expect(page).to have_content('topic1')
expect(page).not_to have_content('topic2')
end
end
context 'when no topics exist' do
it 'renders empty message' do
visit dashboard_topics_path
expect(current_path).to eq dashboard_topics_path
expect(page).to have_content('There are no topics to show')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Explore Topics' do
let(:user) { create(:user) }
let(:project_private_user) { create(:project, :private, namespace: user.namespace) }
let(:project_private) { create(:project, :private) }
let(:project_internal) { create(:project, :internal) }
let(:project_public) { create(:project, :public) }
context 'when no topics exist' do
it 'renders empty message' do
visit explore_topics_path
expect(current_path).to eq explore_topics_path
expect(page).to have_content('There are no topics to show')
end
end
context 'when topics exist' do
before do
project_private_user.update!(topic_list: 'topic1')
project_private.update!(topic_list: 'topic2')
project_internal.update!(topic_list: 'topic3')
project_public.update!(topic_list: 'topic4')
end
context 'as logged-in user' do
before do
sign_in(user)
end
it 'renders correct topics' do
visit explore_topics_path
expect(current_path).to eq explore_topics_path
expect(page).to have_content('topic1')
expect(page).not_to have_content('topic2')
expect(page).to have_content('topic3')
expect(page).to have_content('topic4')
end
end
context 'as anonymous user' do
it 'renders correct topics' do
visit explore_topics_path
expect(current_path).to eq explore_topics_path
expect(page).not_to have_content('topic1')
expect(page).not_to have_content('topic2')
expect(page).not_to have_content('topic3')
expect(page).to have_content('topic4')
end
end
end
end
# frozen_string_literal: true
# TODO: replace 'tag_list' by 'topic_list' as soon as the following MR is merged:
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60834
require 'spec_helper'
RSpec.describe Projects::TopicsFinder do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:other_user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:other_group) { create(:group, :public) }
let_it_be(:user_private_project) { create(:project, :private, namespace: user.namespace) }
let_it_be(:user_public_project) { create(:project, :public, namespace: user.namespace) }
let_it_be(:other_user_private_project) { create(:project, :private, namespace: other_user.namespace) }
let_it_be(:other_user_public_project) { create(:project, :public, namespace: other_user.namespace) }
let_it_be(:group_private_project) { create(:project, :private, group: group) }
let_it_be(:group_public_project) { create(:project, :public, group: group) }
let_it_be(:other_group_private_project) { create(:project, :private, group: other_group) }
let_it_be(:other_group_public_project) { create(:project, :public, group: other_group) }
before do
group.add_developer(user)
other_group.add_developer(other_user)
end
context 'count' do
before do
user_public_project.update!(tag_list: 'aaa, bbb, ccc, ddd')
other_user_public_project.update!(tag_list: 'bbb, ccc, ddd')
group_public_project.update!(tag_list: 'ccc, ddd')
other_group_public_project.update!(tag_list: 'ddd')
end
it 'returns topics with correct count' do
topics = described_class.new.execute
expect(topics.to_h { |topic| [topic.name, topic.count] }).to match({ "aaa" => 1, "bbb" => 2, "ccc" => 3, "ddd" => 4 })
end
end
context 'filter projects' do
before do
user_private_project.update!(tag_list: 'topic1')
user_public_project.update!(tag_list: 'topic2')
other_user_private_project.update!(tag_list: 'topic3')
other_user_public_project.update!(tag_list: 'topic4')
group_private_project.update!(tag_list: 'topic5')
group_public_project.update!(tag_list: 'topic6')
other_group_private_project.update!(tag_list: 'topic7')
other_group_public_project.update!(tag_list: 'topic8')
end
context 'with current_user' do
using RSpec::Parameterized::TableSyntax
where(:params, :expected_topics) do
{} | %w[topic1 topic2 topic4 topic5 topic6 topic8]
{ all_available: true } | %w[topic1 topic2 topic4 topic5 topic6 topic8]
{ all_available: false } | %w[topic1 topic2 topic5 topic6]
end
with_them do
it 'returns correct topics of authorized projects and/or public projects' do
topics = described_class.new(current_user: user, params: params).execute
expect(topics.map(&:name)).to contain_exactly(*expected_topics)
end
end
end
context 'without current_user' do
using RSpec::Parameterized::TableSyntax
where(:params, :expected_topics) do
{} | %w[topic2 topic4 topic6 topic8]
{ all_available: true } | %w[topic2 topic4 topic6 topic8]
{ all_available: false } | %w[topic2 topic4 topic6 topic8]
end
with_them do
it 'returns correct topics of public projects only' do
topics = described_class.new(current_user: nil, params: params).execute
expect(topics.map(&:name)).to contain_exactly(*expected_topics)
end
end
end
end
context 'filter by name' do
before do
user_public_project.update!(tag_list: 'aaabbb, bbbccc, dDd, dddddd')
end
using RSpec::Parameterized::TableSyntax
where(:search, :expected_topics) do
'' | %w[aaabbb bbbccc dDd dddddd]
'aaabbb' | %w[aaabbb]
'bbb' | %w[aaabbb bbbccc]
'ccc' | %w[bbbccc]
'DDD' | %w[dDd dddddd]
'zzz' | %w[]
end
with_them do
it 'returns correct topics filtered by name' do
topics = described_class.new(params: { name: search }).execute
expect(topics.map(&:name)).to contain_exactly(*expected_topics)
end
end
end
context 'sort by attribute' do
before do
user_public_project.update!(tag_list: 'aaa, bbb, ccc, ddd')
other_user_public_project.update!(tag_list: 'bbb, ccc, ddd')
group_public_project.update!(tag_list: 'bbb, ccc')
other_group_public_project.update!(tag_list: 'ccc')
end
using RSpec::Parameterized::TableSyntax
where(:sort, :expected_topics) do
'' | %w[ccc bbb ddd aaa]
'name_asc' | %w[aaa bbb ccc ddd]
'name_desc' | %w[ddd ccc bbb aaa]
'popularity_desc' | %w[ccc bbb ddd aaa]
'invalid_sort' | %w[ccc bbb ddd aaa]
end
with_them do
it 'returns topics in correct order' do
topics = described_class.new(params: { sort: sort }).execute
expect(topics.map(&:name)).to match(expected_topics)
end
end
end
end
end
......@@ -12,7 +12,7 @@ RSpec.describe DashboardHelper do
describe '#dashboard_nav_links' do
it 'has all the expected links by default' do
menu_items = [:projects, :groups, :activity, :milestones, :snippets]
menu_items = [:projects, :groups, :activity, :milestones, :snippets, :topics]
expect(helper.dashboard_nav_links).to include(*menu_items)
end
......
......@@ -12,7 +12,7 @@ RSpec.describe ExploreHelper do
describe '#explore_nav_links' do
it 'has all the expected links by default' do
menu_items = [:projects, :groups, :snippets]
menu_items = [:projects, :groups, :snippets, :topics]
expect(helper.explore_nav_links).to contain_exactly(*menu_items)
end
......
......@@ -71,6 +71,12 @@ RSpec.describe Nav::TopNavHelper do
icon: 'snippet',
id: 'snippets',
title: 'Snippets'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/topics',
icon: 'label',
id: 'topics',
title: 'Topics'
)
]
expect(subject[:primary]).to eq(expected_primary)
......@@ -95,6 +101,12 @@ RSpec.describe Nav::TopNavHelper do
id: 'snippets-shortcut',
title: 'Snippets',
css_class: 'dashboard-shortcuts-snippets'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/topics',
id: 'topics-shortcut',
title: 'Topics',
css_class: 'dashboard-shortcuts-topics'
)
]
expect(subject[:shortcuts]).to eq(expected_shortcuts)
......
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