From ab5126478dcdab46106534085ac1d83860a49a1a Mon Sep 17 00:00:00 2001 From: Olivier El Mekki Date: Thu, 15 Jun 2023 14:21:39 +0200 Subject: [PATCH 1/5] Add RSS feed on topic page This feature adds a RSS feed for topics, so that we can follow topics we like and be notified when new projects are being released. In order to avoid spamming the same projects multiple times, the projects are sorted by id, thus creation date. So the feed is about discovering new projects, and not, for example, getting news on updates for existing projects (for that, an user may follow the RSS feed on the project activity page). Changelog: added --- .../explore/projects_controller.rb | 8 ++++ .../explore/projects/_project.atom.builder | 14 +++++++ app/views/explore/projects/topic.atom.builder | 9 ++++ app/views/explore/projects/topic.html.haml | 4 +- config/routes/explore.rb | 2 +- doc/user/project/working_with_projects.md | 4 ++ .../explore/projects_controller_spec.rb | 41 +++++++++++++++++++ spec/features/atom/topics_spec.rb | 34 +++++++++++++++ .../explore/projects/topic.html.haml_spec.rb | 24 +++++++++++ 9 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 app/views/explore/projects/_project.atom.builder create mode 100644 app/views/explore/projects/topic.atom.builder create mode 100644 spec/features/atom/topics_spec.rb create mode 100644 spec/views/explore/projects/topic.html.haml_spec.rb diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 577bd04d656fd1..776e1285ae8f9e 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -83,6 +83,14 @@ def topic params[:topic] = @topic.name @projects = load_projects + + respond_to do |format| + format.html + format.atom do + @projects = @projects.projects_order_id_desc.limit(20) + render layout: 'xml' + end + end end private diff --git a/app/views/explore/projects/_project.atom.builder b/app/views/explore/projects/_project.atom.builder new file mode 100644 index 00000000000000..f0500901a73f74 --- /dev/null +++ b/app/views/explore/projects/_project.atom.builder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +xml.entry do + xml.title project.name + xml.link href: project_url(project), rel: "alternate", type: "text/html" + xml.id project_url(project) + xml.updated project.created_at + + if project.description.present? + xml.summary(type: "xhtml") do |summary| + summary << project.description + end + end +end diff --git a/app/views/explore/projects/topic.atom.builder b/app/views/explore/projects/topic.atom.builder new file mode 100644 index 00000000000000..4712d415daa00b --- /dev/null +++ b/app/views/explore/projects/topic.atom.builder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +xml.title @topic.name +xml.link href: topic_explore_projects_url(@topic.name, rss_url_options), rel: "self", type: "application/atom+xml" +xml.link href: topic_explore_projects_url(@topic.name), rel: "alternate", type: "text/html" +xml.id topic_explore_projects_url(@topic.id) +xml.updated @projects[0].updated_at.xmlschema if @projects[0] + +xml << render(@projects) if @projects.any? diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml index b26abefcb0e623..c1a569f77ce9a2 100644 --- a/app/views/explore/projects/topic.html.haml +++ b/app/views/explore/projects/topic.html.haml @@ -22,9 +22,11 @@ %div{ class: container_class } .gl-py-5.gl-border-gray-100.gl-border-b-solid.gl-border-b-1 %h3.gl-m-0= _('Projects with this topic') - .top-area.gl-pt-2.gl-pb-2 + .top-area.gl-pt-2.gl-pb-2.gl-justify-content-space-between .nav-controls = render 'shared/projects/search_form' = render 'filter' + = link_to topic_explore_projects_path(@topic.name, rss_url_options), title: _("Subscribe to RSS feed"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do + = sprite_icon('rss', css_class: 'gl-icon') = render 'projects', projects: @projects diff --git a/config/routes/explore.rb b/config/routes/explore.rb index 6ddf4d2313861c..6777571bb6830a 100644 --- a/config/routes/explore.rb +++ b/config/routes/explore.rb @@ -6,7 +6,7 @@ get :trending get :starred get :topics - get 'topics/:topic_name', action: :topic, as: :topic, constraints: { topic_name: /.+/ } + get 'topics/:topic_name', action: :topic, as: :topic, constraints: { format: /(html|atom)/, topic_name: /.+?/ } end end diff --git a/doc/user/project/working_with_projects.md b/doc/user/project/working_with_projects.md index 5bd7c12ed313a6..ea937ed9244874 100644 --- a/doc/user/project/working_with_projects.md +++ b/doc/user/project/working_with_projects.md @@ -55,6 +55,10 @@ To explore project topics: The **Explore topics** page shows a list of topics, sorted by the number of associated projects. +If you want to know when new projects are added to the topic, you can use +its RSS feed for that, which is the RSS icon on the right of the filter +bar. + You can assign topics to a project on the [Project Settings page](settings/index.md#assign-topics-to-a-project). If you're an instance administrator, you can administer all project topics from the diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index e73e115b77ded1..52f39c649739c4 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -122,6 +122,47 @@ end end end + + describe 'GET #topic.atom' do + context 'when topic does not exist' do + it 'renders a 404 error' do + get :topic, format: :atom, params: { topic_name: 'topic1' } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when topic exists' do + let(:topic) { create(:topic, name: 'topic1') } + let_it_be(:older_project) { create(:project, :public, updated_at: 1.day.ago) } + let_it_be(:newer_project) { create(:project, :public, updated_at: 2.days.ago) } + + before do + create(:project_topic, project: older_project, topic: topic) + create(:project_topic, project: newer_project, topic: topic) + end + + it 'renders the template' do + get :topic, format: :atom, params: { topic_name: 'topic1' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('topic', layout: :xml) + end + + it 'sorts repos by descending creation date' do + get :topic, format: :atom, params: { topic_name: 'topic1' } + + expect(assigns(:projects)).to match_array [newer_project, older_project] + end + + it 'finds topic by case insensitive name' do + get :topic, format: :atom, params: { topic_name: 'TOPIC1' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('topic', layout: :xml) + end + end + end end shared_examples "blocks high page numbers" do diff --git a/spec/features/atom/topics_spec.rb b/spec/features/atom/topics_spec.rb new file mode 100644 index 00000000000000..a42bf4712aa740 --- /dev/null +++ b/spec/features/atom/topics_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Topic Feed", feature_category: :groups_and_projects do + let_it_be(:topic) { create(:topic, name: 'test-topic', title: 'Test topic') } + let_it_be(:project1) { create(:project, :public, topic_list: topic.name) } + let_it_be(:project2) { create(:project, :public, topic_list: topic.name) } + + context 'when topic does not exist' do + let(:path) { topic_explore_projects_path(topic_name: 'non-existing', format: 'atom') } + + it 'renders 404' do + visit path + + expect(status_code).to eq(404) + end + end + + context 'when topic exists' do + before do + visit topic_explore_projects_path(topic_name: topic.name, format: 'atom') + end + + it "renders topic atom feed" do + expect(body).to have_selector('feed title') + end + + it "has project entries" do + expect(body).to have_content(project1.name) + expect(body).to have_content(project2.name) + end + end +end diff --git a/spec/views/explore/projects/topic.html.haml_spec.rb b/spec/views/explore/projects/topic.html.haml_spec.rb new file mode 100644 index 00000000000000..1d2085b3be6d47 --- /dev/null +++ b/spec/views/explore/projects/topic.html.haml_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'explore/projects/topic.html.haml', feature_category: :groups_and_projects do + let(:topic) { build_stubbed(:topic, name: 'test-topic', title: 'Test topic') } + let(:project) { build_stubbed(:project, :public, topic_list: topic.name) } + + before do + assign(:topic, topic) + assign(:projects, [project]) + + controller.params[:controller] = 'explore/projects' + controller.params[:action] = 'topic' + + allow(view).to receive(:current_user).and_return(nil) + + render + end + + it 'renders atom feed button with matching path' do + expect(rendered).to have_link(href: topic_explore_projects_path(topic.name, format: 'atom')) + end +end -- GitLab From a974db4e0cd6b18ff40d2ca44481f6838f392093 Mon Sep 17 00:00:00 2001 From: Olivier El Mekki Date: Thu, 22 Jun 2023 14:53:37 +0200 Subject: [PATCH 2/5] ADD title mentioning explicitly what the feed is about It turns out from discussion on the MR page that not everybody understand instinctively what that "Subscribe to RSS Feed" button is actually subscribing to, so it now has a more explicit title. --- app/views/explore/projects/topic.html.haml | 2 +- locale/gitlab.pot | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml index c1a569f77ce9a2..bba0c1b9ca0c53 100644 --- a/app/views/explore/projects/topic.html.haml +++ b/app/views/explore/projects/topic.html.haml @@ -26,7 +26,7 @@ .nav-controls = render 'shared/projects/search_form' = render 'filter' - = link_to topic_explore_projects_path(@topic.name, rss_url_options), title: _("Subscribe to RSS feed"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do + = link_to topic_explore_projects_path(@topic.name, rss_url_options), title: s_("Topics|Subscribe to the new projects feed"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do = sprite_icon('rss', css_class: 'gl-icon') = render 'projects', projects: @projects diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ee98ce52a0c9ce..943112f4e0d7ba 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -48233,6 +48233,9 @@ msgstr "" msgid "Topics could not be merged!" msgstr "" +msgid "Topics|Subscribe to the new projects feed" +msgstr "" + msgid "Total" msgstr "" -- GitLab From 42e660a680f8cd8ade4cdff49eef03daed8b45cf Mon Sep 17 00:00:00 2001 From: Olivier El Mekki Date: Mon, 26 Jun 2023 18:39:01 +0200 Subject: [PATCH 3/5] REFACTOR backend/test review Changes related to code review for backend and tests. --- .../explore/projects_controller.rb | 3 ++- .../explore/projects_controller_spec.rb | 23 +++++++++++++++++++ spec/features/atom/topics_spec.rb | 15 ++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 776e1285ae8f9e..b3a1b510db9c76 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -10,6 +10,7 @@ class Explore::ProjectsController < Explore::ApplicationController MIN_SEARCH_LENGTH = 3 PAGE_LIMIT = 50 + RSS_ENTRIES_LIMIT = 20 before_action :set_non_archived_param before_action :set_sorting @@ -87,7 +88,7 @@ def topic respond_to do |format| format.html format.atom do - @projects = @projects.projects_order_id_desc.limit(20) + @projects = @projects.projects_order_id_desc.limit(RSS_ENTRIES_LIMIT) render layout: 'xml' end end diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index 52f39c649739c4..7792bc44eb2eb6 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -161,6 +161,29 @@ expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template('topic', layout: :xml) end + + describe 'when topic contains more than 20 projects' do + let_it_be(:other_projects) { Array.new(22).map { create(:project, :public) } } + + before do + other_projects.each do |other_project| + create(:project_topic, project: other_project, topic: topic) + end + end + + it 'does not assigns more than 20 projects' do + get :topic, format: :atom, params: { topic_name: 'topic1' } + + expect(assigns(:projects).count).to be(20) + end + + it 'assigns most recent projects' do + get :topic, format: :atom, params: { topic_name: 'topic1' } + + expect(assigns(:projects).to_a).to be_intersect [other_projects[-2], other_projects.last] + expect(assigns(:projects).to_a).not_to be_intersect [other_projects.first, other_projects.second] + end + end end end end diff --git a/spec/features/atom/topics_spec.rb b/spec/features/atom/topics_spec.rb index a42bf4712aa740..078c5b55eeb36e 100644 --- a/spec/features/atom/topics_spec.rb +++ b/spec/features/atom/topics_spec.rb @@ -4,6 +4,7 @@ RSpec.describe "Topic Feed", feature_category: :groups_and_projects do let_it_be(:topic) { create(:topic, name: 'test-topic', title: 'Test topic') } + let_it_be(:empty_topic) { create(:topic, name: 'test-empty-topic', title: 'Test empty topic') } let_it_be(:project1) { create(:project, :public, topic_list: topic.name) } let_it_be(:project2) { create(:project, :public, topic_list: topic.name) } @@ -31,4 +32,18 @@ expect(body).to have_content(project2.name) end end + + context 'when topic is empty' do + before do + visit topic_explore_projects_path(topic_name: empty_topic.name, format: 'atom') + end + + it "renders topic atom feed" do + expect(body).to have_selector('feed title') + end + + it "has no project entry" do + expect(body).to have_no_selector('entry') + end + end end -- GitLab From 3a87f82a0415ef4b83b0f075a748f19aa5c0a064 Mon Sep 17 00:00:00 2001 From: Olivier El Mekki Date: Mon, 26 Jun 2023 19:25:21 +0200 Subject: [PATCH 4/5] FIX Gitlab is running ruby-3.0, Array#intersect? is 3.1 --- spec/controllers/explore/projects_controller_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index 7792bc44eb2eb6..08d453d9b3674e 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -180,8 +180,10 @@ it 'assigns most recent projects' do get :topic, format: :atom, params: { topic_name: 'topic1' } - expect(assigns(:projects).to_a).to be_intersect [other_projects[-2], other_projects.last] - expect(assigns(:projects).to_a).not_to be_intersect [other_projects.first, other_projects.second] + expect(assigns(:projects)).to include other_projects[-2] + expect(assigns(:projects)).to include other_projects.last + expect(assigns(:projects)).not_to include other_projects.first + expect(assigns(:projects)).not_to include other_projects.second end end end -- GitLab From 0cf31281b5e9b34ebd68ad9f4dd13b39a3284155 Mon Sep 17 00:00:00 2001 From: Olivier El Mekki Date: Tue, 27 Jun 2023 16:49:36 +0200 Subject: [PATCH 5/5] FIX dup assertion --- .../explore/projects_controller_spec.rb | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index 08d453d9b3674e..68ae1ca218bf41 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -163,12 +163,8 @@ end describe 'when topic contains more than 20 projects' do - let_it_be(:other_projects) { Array.new(22).map { create(:project, :public) } } - before do - other_projects.each do |other_project| - create(:project_topic, project: other_project, topic: topic) - end + create_list(:project, 22, :public, topics: [topic]) end it 'does not assigns more than 20 projects' do @@ -176,15 +172,6 @@ expect(assigns(:projects).count).to be(20) end - - it 'assigns most recent projects' do - get :topic, format: :atom, params: { topic_name: 'topic1' } - - expect(assigns(:projects)).to include other_projects[-2] - expect(assigns(:projects)).to include other_projects.last - expect(assigns(:projects)).not_to include other_projects.first - expect(assigns(:projects)).not_to include other_projects.second - end end end end -- GitLab