FIX: Better infinite scrolling on categories page (#24831)

This commit refactor CategoryList to remove usage of EmberObject,
hopefully make the code more readable and fixes various edge cases with
lazy loaded categories (third level subcategories not being visible,
subcategories not being visible on category page, requesting for more
pages even if the last one did not return any results, etc).

The problems have always been here, but were not visible because a lot
of the processing was handled by the server and then the result was
serialized. With more of these being moved to the client side for the
lazy category loading, the problems became more obvious.
This commit is contained in:
Bianca Nenciu
2023-12-18 16:46:09 +02:00
committed by GitHub
parent 092633c14f
commit 680cf443f4
6 changed files with 97 additions and 65 deletions

View File

@ -75,14 +75,21 @@ export default class CategoriesDisplay extends Component {
} }
} }
get canLoadMore() {
return this.siteSettings.lazy_load_categories && this.args.loadMore;
}
<template> <template>
<PluginOutlet <PluginOutlet
@name="above-discovery-categories" @name="above-discovery-categories"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash categories=@categories topics=@topics}} @outletArgs={{hash categories=@categories topics=@topics}}
/> />
{{#if this.siteSettings.lazy_load_categories}} {{#if this.canLoadMore}}
<LoadMore @selector=".category" @action={{@loadMore}}> <LoadMore
@selector=".category:not(.muted-categories *)"
@action={{@loadMore}}
>
<this.categoriesComponent <this.categoriesComponent
@categories={{@categories}} @categories={{@categories}}
@topics={{@topics}} @topics={{@topics}}

View File

@ -1,5 +1,4 @@
import ArrayProxy from "@ember/array/proxy"; import ArrayProxy from "@ember/array/proxy";
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { number } from "discourse/lib/formatter"; import { number } from "discourse/lib/formatter";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
@ -9,83 +8,79 @@ import Topic from "discourse/models/topic";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
const MAX_CATEGORIES_LIMIT = 25;
const CategoryList = ArrayProxy.extend({ const CategoryList = ArrayProxy.extend({
init() { init() {
this.set("content", this.categories || []);
this._super(...arguments); this._super(...arguments);
this.set("content", []);
this.set("page", 1); this.set("page", 1);
this.set("fetchedLastPage", false);
}, },
@bind @bind
async loadMore() { async loadMore() {
if (this.isLoading || this.lastPage) { if (this.isLoading || this.fetchedLastPage) {
return; return;
} }
this.set("isLoading", true); this.set("isLoading", true);
const data = { page: this.page + 1, limit: MAX_CATEGORIES_LIMIT }; const data = { page: this.page + 1 };
if (this.parentCategory) {
data.parent_category_id = this.parentCategory.id;
}
const result = await ajax("/categories.json", { data }); const result = await ajax("/categories.json", { data });
this.set("page", data.page); this.set("page", data.page);
if (result.category_list.categories.length === 0) {
result.category_list.categories.forEach((c) => { this.set("fetchedLastPage", true);
const record = Site.current().updateCategory(c); }
this.categories.pushObject(record);
});
this.set("isLoading", false); this.set("isLoading", false);
if (result.category_list.categories.length === 0) {
this.set("lastPage", true);
}
const newCategoryList = CategoryList.categoriesFrom(this.store, result); const newCategoryList = CategoryList.categoriesFrom(this.store, result);
this.categories.pushObjects(newCategoryList.categories); newCategoryList.forEach((c) => this.categories.pushObject(c));
}, },
}); });
CategoryList.reopenClass({ CategoryList.reopenClass({
categoriesFrom(store, result) { categoriesFrom(store, result, parentCategory = null) {
const categories = CategoryList.create({ store }); // Find the period that is most relevant
const list = Category.list(); const statPeriod =
["week", "month"].find(
let statPeriod = "all"; (period) =>
const minCategories = result.category_list.categories.length * 0.66; result.category_list.categories.filter(
["week", "month"].some((period) => {
const filteredCategories = result.category_list.categories.filter(
(c) => c[`topics_${period}`] > 0 (c) => c[`topics_${period}`] > 0
).length >=
result.category_list.categories.length * 0.66
) || "all";
// Update global category list to make sure that `findById` works as
// expected later
result.category_list.categories.forEach((c) =>
Site.current().updateCategory(c)
); );
if (filteredCategories.length >= minCategories) {
statPeriod = period; const categories = CategoryList.create({ store });
return true; result.category_list.categories.forEach((c) => {
c = this._buildCategoryResult(c, statPeriod);
if (
!c.parent_category_id ||
c.parent_category_id === parentCategory?.id
) {
categories.pushObject(c);
} }
}); });
result.category_list.categories.forEach((c) =>
categories.pushObject(this._buildCategoryResult(c, list, statPeriod))
);
return categories; return categories;
}, },
_buildCategoryResult(c, list, statPeriod) { _buildCategoryResult(c, statPeriod) {
if (c.parent_category_id) { if (c.parent_category_id) {
c.parentCategory = list.findBy("id", c.parent_category_id); c.parentCategory = Category.findById(c.parent_category_id);
} }
if (c.subcategory_list) { if (c.subcategory_list) {
c.subcategories = c.subcategory_list.map((subCategory) => c.subcategories = c.subcategory_list.map((subCategory) =>
this._buildCategoryResult(subCategory, list, statPeriod) this._buildCategoryResult(subCategory, statPeriod)
); );
} else if (c.subcategory_ids) { } else if (c.subcategory_ids) {
c.subcategories = c.subcategory_ids.map((scid) => c.subcategories = c.subcategory_ids.map((subCategoryId) =>
list.findBy("id", parseInt(scid, 10)) Category.findById(parseInt(subCategoryId, 10))
); );
} }
@ -99,7 +94,6 @@ CategoryList.reopenClass({
} }
const stat = c[`topics_${statPeriod}`]; const stat = c[`topics_${statPeriod}`];
if ((statPeriod === "week" || statPeriod === "month") && stat > 0) { if ((statPeriod === "week" || statPeriod === "month") && stat > 0) {
const unit = I18n.t(`categories.topic_stat_unit.${statPeriod}`); const unit = I18n.t(`categories.topic_stat_unit.${statPeriod}`);
@ -122,7 +116,7 @@ CategoryList.reopenClass({
c.pickAll = true; c.pickAll = true;
} }
if (Site.currentProp("mobileView")) { if (Site.current().mobileView) {
c.statTotal = I18n.t("categories.topic_stat_all_time", { c.statTotal = I18n.t("categories.topic_stat_all_time", {
count: c.topics_all_time, count: c.topics_all_time,
number: `<span class="value">${number(c.topics_all_time)}</span>`, number: `<span class="value">${number(c.topics_all_time)}</span>`,
@ -137,26 +131,25 @@ CategoryList.reopenClass({
listForParent(store, category) { listForParent(store, category) {
return ajax( return ajax(
`/categories.json?parent_category_id=${category.get("id")}` `/categories.json?parent_category_id=${category.get("id")}`
).then((result) => { ).then((result) =>
return EmberObject.create({ CategoryList.create({
store, store,
categories: this.categoriesFrom(store, result), categories: this.categoriesFrom(store, result, category),
parentCategory: category, parentCategory: category,
}); })
}); );
}, },
list(store) { list(store) {
const getCategories = () => ajax("/categories.json"); return PreloadStore.getAndRemove("categories_list", () =>
return PreloadStore.getAndRemove("categories_list", getCategories).then( ajax("/categories.json")
(result) => { ).then((result) =>
return CategoryList.create({ CategoryList.create({
store, store,
categories: this.categoriesFrom(store, result), categories: this.categoriesFrom(store, result),
can_create_category: result.category_list.can_create_category, can_create_category: result.category_list.can_create_category,
can_create_topic: result.category_list.can_create_topic, can_create_topic: result.category_list.can_create_topic,
}); })
}
); );
}, },
}); });

View File

@ -78,9 +78,9 @@ const Category = RestModel.extend({
} }
}, },
@discourseComputed("subcategories") @discourseComputed("has_children", "subcategories")
isParent(subcategories) { isParent(hasChildren, subcategories) {
return subcategories && subcategories.length > 0; return hasChildren || (subcategories && subcategories.length > 0);
}, },
@discourseComputed("subcategories") @discourseComputed("subcategories")

View File

@ -128,6 +128,16 @@ const Site = RestModel.extend({
"parentCategory", "parentCategory",
this.categoriesById[newCategory.parent_category_id] this.categoriesById[newCategory.parent_category_id]
); );
newCategory.set(
"subcategories",
this.categories.filterBy("parent_category_id", categoryId)
);
if (newCategory.parentCategory) {
if (!newCategory.parentCategory.subcategories) {
newCategory.parentCategory.set("subcategories", []);
}
newCategory.parentCategory.subcategories.pushObject(newCategory);
}
return newCategory; return newCategory;
} }
}, },

View File

@ -156,7 +156,15 @@ class CategoryList
notification_levels = CategoryUser.notification_levels_for(@guardian.user) notification_levels = CategoryUser.notification_levels_for(@guardian.user)
default_notification_level = CategoryUser.default_notification_level default_notification_level = CategoryUser.default_notification_level
if @options[:parent_category_id].blank? if SiteSetting.lazy_load_categories
subcategory_ids = {}
Category
.secured(@guardian)
.where(parent_category_id: @categories.map(&:id))
.pluck(:id, :parent_category_id)
.each { |id, parent_id| (subcategory_ids[parent_id] ||= []) << id }
@categories.each { |c| c.subcategory_ids = subcategory_ids[c.id] || [] }
elsif @options[:parent_category_id].blank?
subcategory_ids = {} subcategory_ids = {}
subcategory_list = {} subcategory_list = {}
to_delete = Set.new to_delete = Set.new

View File

@ -374,4 +374,18 @@ RSpec.describe CategoryList do
expect(category_list.categories[-1].custom_field_preloaded?("bob")).to be_falsey expect(category_list.categories[-1].custom_field_preloaded?("bob")).to be_falsey
end end
end end
describe "lazy_load_categories" do
fab!(:category) { Fabricate(:category, user: admin) }
fab!(:subcategory) { Fabricate(:category, user: admin, parent_category: category) }
before { SiteSetting.lazy_load_categories = true }
it "returns categories with subcategory_ids" do
expect(category_list.categories.size).to eq(3)
expect(
category_list.categories.find { |c| c.id == category.id }.subcategory_ids,
).to contain_exactly(subcategory.id)
end
end
end end