PERF: Paginate loading of tags in edit nav menu tags modal (#22380)

What is the problem?

Before this change, we were relying on the  `/tags` endpoint which 
returned all the tags that are visible to a give user on the site leading to potential performance problems. 
The attribute keys of the response also changes based on the `tags_listed_by_group` site setting. 

What is the fix?

This commit fixes the problems listed above by creating a dedicate `#list` action in the
`TagsController` to handle the listing of the tags in the edit
navigation menu tags modal. This is because the `TagsController#index`
action was created specifically for the `/tags` route and the response
body does not really map well to what we need. The `TagsController#list`
action added here is also much safer since the response is paginated and
we avoid loading a whole bunch of tags upfront.
This commit is contained in:
Alan Guo Xiang Tan 2023-07-04 11:36:39 +08:00 committed by GitHub
parent 6ae4d6cd4c
commit 82d6420e31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 268 additions and 68 deletions

View File

@ -0,0 +1,7 @@
import RESTAdapter from "discourse/adapters/rest";
export default class extends RESTAdapter {
pathFor(_store, _type, findArgs) {
return this.appendQueryParams("/tags/list", findArgs);
}
}

View File

@ -1,4 +1,5 @@
<Sidebar::EditNavigationMenu::Modal
@class="sidebar__edit-navigation-menu__categories-modal"
@title="sidebar.categories_form_modal.title"
@disableSaveButton={{this.saving}}
@save={{this.save}}

View File

@ -1,5 +1,5 @@
<DModal
class="sidebar__edit-navigation-menu__modal"
class={{concat-class "sidebar__edit-navigation-menu__modal" @class}}
@title={{i18n @title}}
@closeModal={{@closeModal}}
>

View File

@ -1,4 +1,5 @@
<Sidebar::EditNavigationMenu::Modal
@class="sidebar__edit-navigation-menu__tags-modal"
@title="sidebar.tags_form_modal.title"
@saving={{this.saving}}
@save={{this.save}}
@ -19,9 +20,13 @@
{{#if this.tagsLoading}}
<div class="spinner"></div>
{{else}}
{{#if (gt this.filteredTags.length 0)}}
{{#each this.filteredTags as |tag|}}
<div class="sidebar-tags-form__tag" data-tag-name={{tag.name}}>
{{#if (gt this.tags.length 0)}}
{{#each this.tags as |tag|}}
<div
class="sidebar-tags-form__tag"
data-tag-name={{tag.name}}
{{did-insert this.didInsertTag}}
>
<Input
id={{concat "sidebar-tags-form__input--" tag.name}}
class="sidebar-tags-form__input"
@ -40,7 +45,7 @@
</span>
<span class="sidebar-tags-form__tag-label-count">
({{tag.count}})
({{tag.topic_count}})
</span>
</p>
</label>
@ -53,4 +58,6 @@
{{/if}}
{{/if}}
</form>
<ConditionalLoadingSpinner @condition={{this.tags.loadingMore}} />
</Sidebar::EditNavigationMenu::Modal>

View File

@ -25,74 +25,80 @@ export default class extends Component {
}
async #loadTags() {
// This is loading all tags upfront and there is no pagination for it. However, this is what we are doing for the
// `/tags` route as well so we have decided to kick this can of worms down the road for now.
this.tagsLoading = true;
const findArgs = {};
if (this.filter !== "") {
findArgs.filter = this.filter;
}
if (this.onlySelected) {
findArgs.only_tags = this.selectedTags.join(",");
}
if (this.onlyUnselected) {
findArgs.exclude_tags = this.selectedTags.join(",");
}
await this.store
.findAll("tag")
.then((result) => {
const tags = [...result.content];
if (result.extras) {
if (result.extras.tag_groups) {
result.extras.tag_groups.forEach((tagGroup) => {
tagGroup.tags.forEach((tag) => {
tags.push(tag);
});
});
}
}
this.tags = tags.sort((a, b) => {
return a.name.localeCompare(b.name);
});
.findAll("listTag", findArgs)
.then((tags) => {
this.tagsLoading = false;
this.tags = tags;
})
.catch((error) => {
popupAjaxError(error);
});
}
get filteredTags() {
return this.tags.reduce((acc, tag) => {
if (this.onlySelected) {
if (this.selectedTags.includes(tag.name) && this.#matchesFilter(tag)) {
acc.push(tag);
}
} else if (this.onlyUnselected) {
if (!this.selectedTags.includes(tag.name) && this.#matchesFilter(tag)) {
acc.push(tag);
}
} else if (this.#matchesFilter(tag)) {
acc.push(tag);
}
@action
didInsertTag(element) {
const tagName = element.dataset.tagName;
const lastTagName = this.tags.content[this.tags.content.length - 1].name;
return acc;
}, []);
if (tagName === lastTagName) {
if (this.observer) {
this.observer.disconnect();
} else {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.tags.loadMore();
}
});
},
{
root: document.querySelector(".modal-body"),
threshold: 1.0,
}
#matchesFilter(tag) {
return (
this.filter.length === 0 || tag.name.toLowerCase().includes(this.filter)
);
}
this.observer.observe(element);
}
}
@action
resetFilter() {
this.onlySelected = false;
this.onlyUnselected = false;
this.#loadTags();
}
@action
filterSelected() {
this.onlySelected = true;
this.onlyUnselected = false;
this.#loadTags();
}
@action
filterUnselected() {
this.onlySelected = false;
this.onlyUnselected = true;
this.#loadTags();
}
@action
@ -102,6 +108,7 @@ export default class extends Component {
#performFiltering(filter) {
this.filter = filter.toLowerCase();
this.#loadTags();
}
@action

View File

@ -104,16 +104,6 @@ acceptance("Sidebar - Logged on user - Tags section", function (needs) {
);
});
test("clicking on section header button", async function (assert) {
await visit("/");
await click(
".sidebar-section[data-section-name='tags'] .sidebar-section-header-button"
);
assert.true(exists(".sidebar-tags-form"), "it shows the tags form modal");
});
test("tags section is displayed with site's top tags when user has not added any tags and there are no default tags configured", async function (assert) {
updateCurrentUser({
sidebar_tags: [],

View File

@ -1,6 +1,6 @@
.sidebar__edit-navigation-menu__modal {
.modal-body {
min-height: 50vh;
min-height: 25vh;
}
}

View File

@ -1,4 +1,4 @@
.sidebar-tags-form-modal {
.sidebar__edit-navigation-menu__tags-modal {
.modal-inner-container {
min-width: var(--modal-max-width);
}

View File

@ -1,4 +1,4 @@
.sidebar-tags-form-modal {
.sidebar__edit-navigation-menu__tags-modal {
.modal-inner-container {
width: 35em;
}

View File

@ -27,6 +27,7 @@ class TagsController < ::ApplicationController
update_notifications
personal_messages
info
list
]
before_action :fetch_tag, only: %i[info create_synonyms destroy_synonym]
@ -99,6 +100,46 @@ class TagsController < ::ApplicationController
end
end
LIST_LIMIT = 51
def list
offset = params[:offset].to_i || 0
tags = guardian.can_admin_tags? ? Tag.all : Tag.used_tags_in_regular_topics(guardian)
load_more_query_params = { offset: offset + 1 }
if filter = params[:filter]
tags = tags.where("LOWER(tags.name) ILIKE ?", "%#{filter.downcase}%")
load_more_query_params[:filter] = filter
end
if only_tags = params[:only_tags]
tags = tags.where("LOWER(tags.name) IN (?)", only_tags.split(",").map(&:downcase))
load_more_query_params[:only_tags] = only_tags
end
if exclude_tags = params[:exclude_tags]
tags = tags.where("LOWER(tags.name) NOT IN (?)", exclude_tags.split(",").map(&:downcase))
load_more_query_params[:exclude_tags] = exclude_tags
end
tags_count = tags.count
tags = tags.order("LOWER(tags.name) ASC").limit(LIST_LIMIT).offset(offset * LIST_LIMIT)
load_more_url = URI("/tags/list.json")
load_more_url.query = URI.encode_www_form(load_more_query_params)
render_serialized(
tags,
TagSerializer,
root: "list_tags",
meta: {
total_rows_list_tags: tags_count,
load_more_list_tags: load_more_url.to_s,
},
)
end
Discourse.filters.each do |filter|
define_method("show_#{filter}") do
@tag_id = params[:tag_id].force_encoding("UTF-8")

View File

@ -1480,6 +1480,7 @@ Discourse::Application.routes.draw do
get "/" => "tags#index"
get "/filter/list" => "tags#index"
get "/filter/search" => "tags#search"
get "/list" => "tags#list"
get "/personal_messages/:username" => "tags#personal_messages",
:constraints => {
username: RouteFormat.username,

View File

@ -1441,4 +1441,148 @@ RSpec.describe TagsController do
expect(tag_user.notification_level).to eq(NotificationLevels.all[:muted])
end
end
describe "#list" do
fab!(:tag3) do
Fabricate(:tag, name: "tag3").tap { |tag| Fabricate.times(1, :topic, tags: [tag]) }
end
fab!(:tag2) do
Fabricate(:tag, name: "tag2").tap { |tag| Fabricate.times(1, :topic, tags: [tag]) }
end
fab!(:tag1) do
Fabricate(:tag, name: "tag").tap { |tag| Fabricate.times(1, :topic, tags: [tag]) }
end
fab!(:tag_not_used_in_topics) { Fabricate(:tag, name: "tag4") }
it "should return 403 for an anonymous user" do
get "/tags/list.json"
expect(response.status).to eq(403)
end
it "should return 404 when tagging is disabled" do
SiteSetting.tagging_enabled = false
sign_in(user)
get "/tags/list.json"
expect(response.status).to eq(404)
end
it "should only return tags used in topics for non admin users" do
stub_const(TagsController, "LIST_LIMIT", 2) do
sign_in(user)
get "/tags/list.json"
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq(
[tag1.name, tag2.name],
)
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(3)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=1",
)
get response.parsed_body["meta"]["load_more_list_tags"]
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq([tag3.name])
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(3)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=2",
)
end
end
it "should return all tags for admin users" do
stub_const(TagsController, "LIST_LIMIT", 2) do
sign_in(admin)
get "/tags/list.json"
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq(
[tag1.name, tag2.name],
)
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(4)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=1",
)
get response.parsed_body["meta"]["load_more_list_tags"]
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq(
[tag3.name, tag_not_used_in_topics.name],
)
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(4)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=2",
)
end
end
it "accepts a `filter` param and filters the tags by tag name" do
sign_in(user)
get "/tags/list.json", params: { filter: "3" }
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq([tag3.name])
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(1)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=1&filter=3",
)
end
it "accepts a `only_tags` param and filters the tags by the given tags" do
sign_in(user)
get "/tags/list.json", params: { only_tags: "#{tag1.name},#{tag3.name}" }
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq(
[tag1.name, tag3.name],
)
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(2)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=1&only_tags=#{tag1.name}%2C#{tag3.name}",
)
end
it "accepts a `exclude_tags` params and filters tags excluding the given tags" do
sign_in(user)
get "/tags/list.json", params: { exclude_tags: "#{tag1.name},#{tag3.name}" }
expect(response.status).to eq(200)
expect(response.parsed_body["list_tags"].map { |tag| tag["name"] }).to eq([tag2.name])
expect(response.parsed_body["meta"]["total_rows_list_tags"]).to eq(1)
expect(response.parsed_body["meta"]["load_more_list_tags"]).to eq(
"/tags/list.json?offset=1&exclude_tags=#{tag1.name}%2C#{tag3.name}",
)
end
end
end

View File

@ -88,16 +88,6 @@ RSpec.describe "Editing sidebar tags navigation", type: :system do
include_examples "a user can edit the sidebar tags navigation", true
end
it "displays the all tags in the modal when `tags_listed_by_group` site setting is true" do
SiteSetting.tags_listed_by_group = true
visit "/latest"
modal = sidebar.click_edit_tags_button
expect(modal).to have_tag_checkboxes([tag1, tag2, tag3, tag4])
end
it "allows a user to filter the tags in the modal by the tag's name" do
visit "/latest"
@ -186,4 +176,16 @@ RSpec.describe "Editing sidebar tags navigation", type: :system do
expect(modal).to have_tag_checkboxes([tag1, tag2, tag3, tag4])
end
it "loads more tags when the user scrolls views the last tag in the modal and there is more tags to load" do
stub_const(TagsController, "LIST_LIMIT", 2) do
visit "/latest"
expect(sidebar).to have_tags_section
modal = sidebar.click_edit_tags_button
expect(modal).to have_tag_checkboxes([tag1, tag2, tag3, tag4])
end
end
end