mirror of
https://github.com/discourse/discourse.git
synced 2026-08-03 09:53:24 -05:00
DEV: Replace ArrayProxy with tracked array for CategoryList (#35404)
Migrates category listing and related UI to tracked built-ins with an array-like proxy for improved reactivity and modernization. - Introduce LegacyArrayLikeObject: a Proxy over TrackedArray preserving array semantics while allowing instance properties/methods - Rewrite CategoryList to use an array-like object; add tracked state (page, isLoading, fetchedLastPage, parentCategory), async list(), and safer loadMore with error handling; deprecate legacy categories/content usage - Update routes/templates to pass CategoryList directly (not .categories), switch to async/await, preserve PreloadStore behavior, and sync TopicTrackingState with the new model - Refactor category components (boxes, boxes-with-topics, only) to ES getters and native array APIs; remove discourseComputed and Ember helpers like firstObject/filterBy - Add tests for array-like object behavior (array methods, inheritance, plugin API modifyClass), CategoryList fetching/parent filtering/stat rendering/pagination, and UI reactivity - Remove ArrayProxy and other deprecated patterns
This commit is contained in:
@@ -10,7 +10,6 @@ import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import categoryColorVariable from "discourse/helpers/category-color-variable";
|
||||
import { categoryBadgeHTML } from "discourse/helpers/category-link";
|
||||
import lazyHash from "discourse/helpers/lazy-hash";
|
||||
import discourseComputed from "discourse/lib/decorators";
|
||||
|
||||
@tagName("section")
|
||||
@classNameBindings(
|
||||
@@ -18,9 +17,8 @@ import discourseComputed from "discourse/lib/decorators";
|
||||
"anyLogos:with-logos:no-logos"
|
||||
)
|
||||
export default class CategoriesBoxesWithTopics extends Component {
|
||||
@discourseComputed("categories.[].uploaded_logo.url")
|
||||
anyLogos() {
|
||||
return this.categories.any((c) => {
|
||||
get anyLogos() {
|
||||
return this.categories.some((c) => {
|
||||
return !isEmpty(c.get("uploaded_logo.url"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import categoryLink, {
|
||||
categoryBadgeHTML,
|
||||
} from "discourse/helpers/category-link";
|
||||
import lazyHash from "discourse/helpers/lazy-hash";
|
||||
import discourseComputed from "discourse/lib/decorators";
|
||||
|
||||
@tagName("section")
|
||||
@classNameBindings(
|
||||
@@ -22,14 +21,12 @@ import discourseComputed from "discourse/lib/decorators";
|
||||
"hasSubcategories:with-subcategories"
|
||||
)
|
||||
export default class CategoriesBoxes extends Component {
|
||||
@discourseComputed("categories.[].uploaded_logo.url")
|
||||
anyLogos() {
|
||||
return this.categories.any((c) => !isEmpty(c.get("uploaded_logo.url")));
|
||||
get anyLogos() {
|
||||
return this.categories.some((c) => !isEmpty(c.get("uploaded_logo.url")));
|
||||
}
|
||||
|
||||
@discourseComputed("categories.[].subcategories")
|
||||
hasSubcategories() {
|
||||
return this.categories.any((c) => !isEmpty(c.get("subcategories")));
|
||||
get hasSubcategories() {
|
||||
return this.categories.some((c) => !isEmpty(c.get("subcategories")));
|
||||
}
|
||||
|
||||
categoryName(category) {
|
||||
|
||||
@@ -48,11 +48,11 @@ export default class CategoriesOnly extends Component {
|
||||
}
|
||||
|
||||
// hide in single category pages
|
||||
if (categories.firstObject.parent_category_id) {
|
||||
if (categories[0].parent_category_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return categories.filterBy("hasMuted");
|
||||
return categories.filter((category) => category.hasMuted);
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* DEPRECATED: Do not use LegacyArrayLikeObject for new code.
|
||||
*
|
||||
* This class is intended ONLY for providing TrackedArray capabilities to support
|
||||
* already existing classes that previously used the ArrayProxy mixin. It should not
|
||||
* be used in new development.
|
||||
*
|
||||
* For new code, use a standard class with tracked properties and @trackedArray for the content property.
|
||||
* Example:
|
||||
*
|
||||
* import { tracked } from '@glimmer/tracking';
|
||||
* import { trackedArray } from "discourse/lib/tracked-tools";
|
||||
*
|
||||
* class MyArrayWrapper {
|
||||
* @tracked someProp;
|
||||
* @trackedArray content = [];
|
||||
* }
|
||||
*
|
||||
* This approach provides reactivity and array capabilities without legacy proxy patterns.
|
||||
*/
|
||||
|
||||
import EmberObject from "@ember/object";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
import deprecated from "discourse/lib/deprecated";
|
||||
|
||||
const EMBER_OBJECT_PROPERTIES = new Set([
|
||||
"constructor",
|
||||
"addObserver",
|
||||
"cacheFor",
|
||||
"decrementProperty",
|
||||
"destroy",
|
||||
"get",
|
||||
"getProperties",
|
||||
"incrementProperty",
|
||||
"init",
|
||||
"notifyPropertyChange",
|
||||
"removeObserver",
|
||||
"set",
|
||||
"setProperties",
|
||||
"toString",
|
||||
"toggleProperty",
|
||||
"willDestroy",
|
||||
"concatenatedProperties",
|
||||
"isDestroyed",
|
||||
"isDestroying",
|
||||
"mergedProperties",
|
||||
]);
|
||||
|
||||
const ARRAY_PROPERTIES = new Set(
|
||||
[
|
||||
...Object.getOwnPropertyNames(Array.prototype),
|
||||
...Object.getOwnPropertySymbols(Array.prototype),
|
||||
] //.filter((prop) => !EMBER_OBJECT_PROPERTIES.has(prop))
|
||||
);
|
||||
|
||||
/**
|
||||
* LegacyArrayLikeObject is an EmberObject that proxies array-like behavior to a TrackedArray,
|
||||
* while exposing additional properties and methods. Access array methods via `.content`.
|
||||
*
|
||||
* @class LegacyArrayLikeObject
|
||||
* @extends EmberObject
|
||||
* @example
|
||||
* const obj = LegacyArrayLikeObject.create({ content: [1,2,3], foo: 'bar' });
|
||||
* obj.content.push(4); // Use .content for array operations
|
||||
* obj.foo // 'bar'
|
||||
*
|
||||
* Note: Must be instantiated via LegacyArrayLikeObject.create().
|
||||
*/
|
||||
export default class LegacyArrayLikeObject extends EmberObject {
|
||||
static #isConstructing = false; // to simulate a private constructor
|
||||
|
||||
/**
|
||||
* Creates an instance of LegacyArrayLikeObject. Must be used instead of `new`.
|
||||
*
|
||||
* @param {Object} attrs - Properties to set on the instance. `content` must be an array.
|
||||
* @returns {LegacyArrayLikeObject}
|
||||
*/
|
||||
static create(attrs = {}) {
|
||||
LegacyArrayLikeObject.#isConstructing = true;
|
||||
|
||||
const { content, ...properties } = attrs;
|
||||
const object = new this(content);
|
||||
|
||||
// on subclasses the fields are initialized after the constructor of the base class
|
||||
// has run with the super() clause.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor
|
||||
// Because of this, to prevent the proxy from getting confused and saving field properties into
|
||||
// the underlying TrackedArray, we need to set the properties after the instance has been created.
|
||||
object.setProperties(properties);
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
#content;
|
||||
|
||||
/**
|
||||
* Constructor is private. Use LegacyArrayLikeObject.create() instead.
|
||||
*
|
||||
* @param {Array} content - The array to wrap. Must be an array.
|
||||
* @throws {TypeError} If not called via .create or if content is not an array.
|
||||
* @private
|
||||
*/
|
||||
constructor(content = []) {
|
||||
super();
|
||||
|
||||
if (!LegacyArrayLikeObject.#isConstructing) {
|
||||
throw new TypeError(
|
||||
`${this.constructor.name} is not constructable. Use the static \`${this.constructor.name}.create()\` method instead.`
|
||||
);
|
||||
}
|
||||
LegacyArrayLikeObject.#isConstructing = false;
|
||||
|
||||
// Validate inputs
|
||||
if (!Array.isArray(content)) {
|
||||
throw new TypeError(
|
||||
`${this.constructor.name}: \`.content\` must be an array`
|
||||
);
|
||||
}
|
||||
|
||||
this.#content =
|
||||
content instanceof TrackedArray ? content : new TrackedArray(content);
|
||||
|
||||
return createProxy(this, this.#content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a property is an EmberObject property or a custom instance property.
|
||||
*
|
||||
* @param {Object} instance - The object instance to check against.
|
||||
* @param {string|symbol} prop - The property name or symbol.
|
||||
* @returns {boolean} True if the property belongs to the instance, false otherwise.
|
||||
*/
|
||||
function isInstanceProperty(instance, prop) {
|
||||
return (
|
||||
Reflect.has(instance, prop) &&
|
||||
(EMBER_OBJECT_PROPERTIES.has(prop) || !ARRAY_PROPERTIES.has(prop))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a property is an Array property.
|
||||
*
|
||||
* @param {string|symbol} prop - The property name or symbol.
|
||||
* @returns {boolean} True if the property is an Array property, false otherwise.
|
||||
*/
|
||||
function isArrayProperty(prop) {
|
||||
return ARRAY_PROPERTIES.has(prop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a property is a numeric array index (string or number).
|
||||
* Accepts numeric strings or numbers (e.g. '0', 0, '12', 12).
|
||||
*
|
||||
* @param {string|number} prop - The property to check.
|
||||
* @returns {boolean} True if the property is a numeric index, false otherwise.
|
||||
*/
|
||||
function isNumericIndexProp(prop) {
|
||||
// Accepts numeric strings or numbers (e.g. '0', 0, '12', 12)
|
||||
return (
|
||||
(typeof prop === "string" && /^\d+$/.test(prop)) ||
|
||||
(typeof prop === "number" &&
|
||||
Number.isFinite(prop) &&
|
||||
Number.isInteger(prop))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a deprecation warning for direct array property access.
|
||||
*
|
||||
* @param {string} instanceName - The name of the instance's constructor.
|
||||
* @param {string|number|symbol} prop - The property being accessed.
|
||||
* @param {boolean} [isIndex=false] - Whether the property is a numeric index.
|
||||
*/
|
||||
function warnArrayDeprecation(instanceName, prop, isIndex = false) {
|
||||
const propName = isIndex ? `[${prop}]` : `.${prop.toString()}`;
|
||||
deprecated(
|
||||
`Accessing \`(${instanceName} instance)${propName}\` directly is deprecated. ` +
|
||||
`Access the array directly via \`.content\` instead. ` +
|
||||
`For example, use \`(${instanceName} instance).content${propName}\` instead of \`(${instanceName} instance)${propName}\`.`,
|
||||
{
|
||||
id: "discourse.legacy-array-like-object.proxied-array",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a proxy that intercepts property access, forwarding instance properties to the LegacyArrayLikeObject
|
||||
* and array properties to the underlying array.
|
||||
* Emits deprecation warnings for direct array property access.
|
||||
*
|
||||
* @param {LegacyArrayLikeObject} instance - The LegacyArrayLikeObject instance.
|
||||
* @param {TrackedArray} trackedItems - The underlying tracked array.
|
||||
* @returns {Proxy} Proxy object that combines instance and array behaviors.
|
||||
*/
|
||||
function createProxy(instance, trackedItems) {
|
||||
const instanceName = instance.constructor.name;
|
||||
|
||||
return new Proxy(trackedItems, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "content") {
|
||||
return target;
|
||||
}
|
||||
|
||||
if (isInstanceProperty(instance, prop)) {
|
||||
return Reflect.get(instance, prop, receiver);
|
||||
}
|
||||
|
||||
if (isArrayProperty(prop) || isNumericIndexProp(prop)) {
|
||||
warnArrayDeprecation(instanceName, prop, isNumericIndexProp(prop));
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
|
||||
set(target, prop, value, receiver) {
|
||||
if (prop === "content") {
|
||||
throw new Error(
|
||||
`You cannot override the content property of an ${instanceName}, mutate the array instead.`
|
||||
);
|
||||
}
|
||||
|
||||
if (isInstanceProperty(instance, prop)) {
|
||||
return Reflect.set(instance, prop, value, receiver);
|
||||
}
|
||||
|
||||
if (isArrayProperty(prop) || isNumericIndexProp(prop)) {
|
||||
warnArrayDeprecation(instanceName, prop, isNumericIndexProp(prop));
|
||||
}
|
||||
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
},
|
||||
|
||||
has(target, prop) {
|
||||
return Reflect.has(instance, prop) || Reflect.has(target, prop);
|
||||
},
|
||||
|
||||
getPrototypeOf() {
|
||||
return instance.constructor.prototype;
|
||||
},
|
||||
|
||||
ownKeys(target) {
|
||||
const instanceKeys = Reflect.ownKeys(instance);
|
||||
const targetKeys = Reflect.ownKeys(target);
|
||||
|
||||
return [...new Set([...instanceKeys, ...targetKeys])];
|
||||
},
|
||||
|
||||
defineProperty(target, prop, descriptor) {
|
||||
return Reflect.defineProperty(instance, prop, descriptor);
|
||||
},
|
||||
|
||||
deleteProperty(target, prop) {
|
||||
return Reflect.deleteProperty(instance, prop);
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (isInstanceProperty(instance, prop)) {
|
||||
return Reflect.getOwnPropertyDescriptor(instance, prop);
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,142 +1,220 @@
|
||||
import ArrayProxy from "@ember/array/proxy";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import deprecated from "discourse/lib/deprecated";
|
||||
import { number } from "discourse/lib/formatter";
|
||||
import LegacyArrayLikeObject from "discourse/lib/legacy-array-like-object";
|
||||
import PreloadStore from "discourse/lib/preload-store";
|
||||
import { trackedArray } from "discourse/lib/tracked-tools";
|
||||
import Site from "discourse/models/site";
|
||||
import Topic from "discourse/models/topic";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class CategoryList extends ArrayProxy {
|
||||
const STAT_PERIODS = ["week", "month"];
|
||||
|
||||
/**
|
||||
* Represents a list of categories with their related metadata and functionality
|
||||
*/
|
||||
export default class CategoryList extends LegacyArrayLikeObject {
|
||||
/**
|
||||
* Creates category objects from API result data
|
||||
*
|
||||
* @param {Object} store - The store instance
|
||||
* @param {Object} result - The API result containing category data
|
||||
* @param {Object} parentCategory - Optional parent category
|
||||
* @returns {CategoryList} A new CategoryList instance with the processed categories
|
||||
*/
|
||||
static categoriesFrom(store, result, parentCategory = null) {
|
||||
// Find the period that is most relevant
|
||||
const list = result?.category_list?.categories || [];
|
||||
const statPeriod =
|
||||
["week", "month"].find(
|
||||
STAT_PERIODS.find(
|
||||
(period) =>
|
||||
result.category_list.categories.filter(
|
||||
(c) => c[`topics_${period}`] > 0
|
||||
).length >=
|
||||
result.category_list.categories.length * 0.66
|
||||
list.filter((c) => c?.[`topics_${period}`] > 0).length >=
|
||||
list.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)
|
||||
);
|
||||
list.forEach((c) => Site.current().updateCategory(c));
|
||||
|
||||
const categories = CategoryList.create({ store });
|
||||
result.category_list.categories.forEach((c) => {
|
||||
list.forEach((c) => {
|
||||
c = this._buildCategoryResult(c, statPeriod);
|
||||
if (
|
||||
(parentCategory && c.parent_category_id === parentCategory.id) ||
|
||||
(!parentCategory && !c.parent_category_id)
|
||||
) {
|
||||
categories.pushObject(c);
|
||||
categories.content.push(c);
|
||||
}
|
||||
});
|
||||
return categories;
|
||||
}
|
||||
|
||||
static _buildCategoryResult(c, statPeriod) {
|
||||
if (c.topics) {
|
||||
c.topics = c.topics.map((t) => Topic.create(t));
|
||||
/**
|
||||
* Builds a category result object with stats and topic data
|
||||
* @param {Object} rawCategoryData - The raw category data
|
||||
* @param {string} statPeriod - The period to use for stats ('week', 'month', or 'all')
|
||||
* @returns {Category} The processed category object
|
||||
* @private
|
||||
*/
|
||||
static _buildCategoryResult(rawCategoryData, statPeriod) {
|
||||
if (rawCategoryData.topics?.length) {
|
||||
rawCategoryData.topics = rawCategoryData.topics.map((t) =>
|
||||
Topic.create(t)
|
||||
);
|
||||
}
|
||||
|
||||
const stat = c[`topics_${statPeriod}`];
|
||||
if ((statPeriod === "week" || statPeriod === "month") && stat > 0) {
|
||||
const stat = rawCategoryData[`topics_${statPeriod}`];
|
||||
const isTimedPeriod = statPeriod === "week" || statPeriod === "month";
|
||||
if (isTimedPeriod && stat > 0) {
|
||||
const unit = i18n(`categories.topic_stat_unit.${statPeriod}`);
|
||||
|
||||
c.stat = i18n("categories.topic_stat", {
|
||||
rawCategoryData.stat = i18n("categories.topic_stat", {
|
||||
count: stat, // only used to correctly pluralize the string
|
||||
number: `<span class="value">${number(stat)}</span>`,
|
||||
unit: `<span class="unit">${unit}</span>`,
|
||||
});
|
||||
|
||||
c.statTitle = i18n(`categories.topic_stat_sentence_${statPeriod}`, {
|
||||
count: stat,
|
||||
});
|
||||
rawCategoryData.statTitle = i18n(
|
||||
`categories.topic_stat_sentence_${statPeriod}`,
|
||||
{
|
||||
count: stat,
|
||||
}
|
||||
);
|
||||
|
||||
c.pickAll = false;
|
||||
rawCategoryData.pickAll = false;
|
||||
} else {
|
||||
c.stat = `<span class="value">${number(c.topics_all_time)}</span>`;
|
||||
c.statTitle = i18n("categories.topic_sentence", {
|
||||
count: c.topics_all_time,
|
||||
rawCategoryData.stat = `<span class="value">${number(rawCategoryData.topics_all_time)}</span>`;
|
||||
rawCategoryData.statTitle = i18n("categories.topic_sentence", {
|
||||
count: rawCategoryData.topics_all_time,
|
||||
});
|
||||
c.pickAll = true;
|
||||
rawCategoryData.pickAll = true;
|
||||
}
|
||||
|
||||
if (Site.current().mobileView) {
|
||||
c.statTotal = i18n("categories.topic_stat_all_time", {
|
||||
count: c.topics_all_time,
|
||||
number: `<span class="value">${number(c.topics_all_time)}</span>`,
|
||||
rawCategoryData.statTotal = i18n("categories.topic_stat_all_time", {
|
||||
count: rawCategoryData.topics_all_time,
|
||||
number: `<span class="value">${number(rawCategoryData.topics_all_time)}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
const record = Site.current().updateCategory(c);
|
||||
const record = Site.current().updateCategory(rawCategoryData);
|
||||
record.setupGroupsAndPermissions();
|
||||
return record;
|
||||
}
|
||||
|
||||
static listForParent(store, category) {
|
||||
/**
|
||||
* @deprecated Use list() instead
|
||||
*/
|
||||
static listForParent(store, parentCategory) {
|
||||
deprecated(
|
||||
"The listForParent method of CategoryList is deprecated. Use list instead",
|
||||
{ id: "discourse.category-list.listForParent" }
|
||||
);
|
||||
|
||||
return CategoryList.list(store, category);
|
||||
return CategoryList.list(store, parentCategory);
|
||||
}
|
||||
|
||||
static list(store, parentCategory = null) {
|
||||
return PreloadStore.getAndRemove("categories_list", () => {
|
||||
const data = {};
|
||||
if (parentCategory) {
|
||||
data.parent_category_id = parentCategory?.id;
|
||||
/**
|
||||
* Fetches and creates a list of categories
|
||||
*
|
||||
* @param {Object} store - The store instance
|
||||
* @param {Object} parentCategory - Optional parent category to filter by
|
||||
* @returns {Promise<CategoryList>} A promise that resolves to the CategoryList
|
||||
*/
|
||||
static async list(store, parentCategory = null) {
|
||||
const result = await PreloadStore.getAndRemove(
|
||||
"categories_list",
|
||||
async () => {
|
||||
const data = {};
|
||||
if (parentCategory) {
|
||||
data.parent_category_id = parentCategory.id;
|
||||
}
|
||||
return ajax("/categories.json", { data });
|
||||
}
|
||||
return ajax("/categories.json", { data });
|
||||
}).then((result) => {
|
||||
return CategoryList.create({
|
||||
store,
|
||||
categories: this.categoriesFrom(store, result, parentCategory),
|
||||
parentCategory,
|
||||
can_create_category: result.category_list.can_create_category,
|
||||
can_create_topic: result.category_list.can_create_topic,
|
||||
});
|
||||
);
|
||||
|
||||
const categoryList = result?.category_list || {};
|
||||
return CategoryList.create({
|
||||
store,
|
||||
categories: this.categoriesFrom(store, result, parentCategory).content,
|
||||
parentCategory,
|
||||
can_create_category: categoryList.can_create_category,
|
||||
can_create_topic: categoryList.can_create_topic,
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
this.set("content", this.categories || []);
|
||||
super.init(...arguments);
|
||||
this.set("page", 1);
|
||||
this.set("fetchedLastPage", false);
|
||||
/**
|
||||
* Creates a new CategoryList instance
|
||||
*
|
||||
* @param {Object} attrs - The attributes to initialize with
|
||||
* @returns {CategoryList} A new CategoryList instance
|
||||
*/
|
||||
static create(attrs = {}) {
|
||||
const { categories, ...properties } = attrs;
|
||||
return super.create({ content: categories, ...properties });
|
||||
}
|
||||
|
||||
@tracked can_create_category;
|
||||
@tracked can_create_topic;
|
||||
@tracked fetchedLastPage = false;
|
||||
@tracked isLoading = false;
|
||||
@tracked page = 1;
|
||||
@tracked parentCategory;
|
||||
@trackedArray topics;
|
||||
store;
|
||||
|
||||
/**
|
||||
* @returns {Proxy} The proxied content for compatibility
|
||||
* @deprecated use the category list instance instead
|
||||
*/
|
||||
get categories() {
|
||||
deprecated(
|
||||
"Using `CategoryList.categories` property is deprecated. Use `CategoryList.content` instead",
|
||||
{ id: "discourse.category-list.categories" }
|
||||
);
|
||||
return this.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads more categories from the server
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@bind
|
||||
async loadMore() {
|
||||
if (this.isLoading || this.fetchedLastPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("isLoading", true);
|
||||
this.isLoading = true;
|
||||
|
||||
const data = { page: this.page + 1 };
|
||||
if (this.parentCategory) {
|
||||
data.parent_category_id = this.parentCategory.id;
|
||||
try {
|
||||
const nextPage = this.page + 1;
|
||||
const data = {
|
||||
page: nextPage,
|
||||
...(this.parentCategory && {
|
||||
parent_category_id: this.parentCategory.id,
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await ajax("/categories.json", { data });
|
||||
|
||||
this.page = nextPage;
|
||||
|
||||
const newItems = CategoryList.categoriesFrom(
|
||||
this.store,
|
||||
result,
|
||||
this.parentCategory
|
||||
).content;
|
||||
|
||||
if (!newItems.length) {
|
||||
this.fetchedLastPage = true;
|
||||
} else {
|
||||
newItems.forEach((c) => this.content.push(c));
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
const result = await ajax("/categories.json", { data });
|
||||
|
||||
this.set("page", data.page);
|
||||
if (result.category_list.categories.length === 0) {
|
||||
this.set("fetchedLastPage", true);
|
||||
}
|
||||
this.set("isLoading", false);
|
||||
|
||||
CategoryList.categoriesFrom(
|
||||
this.store,
|
||||
result,
|
||||
this.parentCategory
|
||||
).forEach((c) => this.categories.pushObject(c));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
|
||||
controllerName = "discovery/categories";
|
||||
|
||||
async findCategories(parentCategory) {
|
||||
let model;
|
||||
let categoryList;
|
||||
|
||||
let style =
|
||||
this.site.desktopView && this.siteSettings.desktop_category_page_style;
|
||||
@@ -30,17 +30,20 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
|
||||
style === "categories_and_latest_topics" ||
|
||||
style === "categories_and_latest_topics_created_date"
|
||||
) {
|
||||
model = await this._findCategoriesAndTopics("latest", parentCategory);
|
||||
categoryList = await this._findCategoriesAndTopics(
|
||||
"latest",
|
||||
parentCategory
|
||||
);
|
||||
} else if (style === "categories_and_top_topics") {
|
||||
model = await this._findCategoriesAndTopics("top", parentCategory);
|
||||
categoryList = await this._findCategoriesAndTopics("top", parentCategory);
|
||||
} else {
|
||||
// The server may have serialized this. Based on the logic above, we don't need it
|
||||
// so remove it to avoid it being used later by another TopicList route.
|
||||
PreloadStore.remove("topic_list");
|
||||
model = await CategoryList.list(this.store, parentCategory);
|
||||
categoryList = await CategoryList.list(this.store, parentCategory);
|
||||
}
|
||||
|
||||
return model;
|
||||
return categoryList;
|
||||
}
|
||||
|
||||
async model(params) {
|
||||
@@ -53,14 +56,15 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
|
||||
: Category.findBySlugPathWithID(params.category_slug_path_with_id);
|
||||
}
|
||||
|
||||
return this.findCategories(parentCategory).then((model) => {
|
||||
const tracking = this.topicTrackingState;
|
||||
if (tracking) {
|
||||
tracking.sync(model, "categories");
|
||||
tracking.trackIncoming("categories");
|
||||
}
|
||||
return model;
|
||||
});
|
||||
const model = await this.findCategories(parentCategory);
|
||||
|
||||
const tracking = this.topicTrackingState;
|
||||
if (tracking) {
|
||||
tracking.sync(model, "categories");
|
||||
tracking.trackIncoming("categories");
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
_loadBefore(store) {
|
||||
@@ -125,7 +129,7 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
|
||||
this.store,
|
||||
result,
|
||||
parentCategory
|
||||
),
|
||||
).content,
|
||||
parentCategory,
|
||||
topics: TopicList.topicsFrom(this.store, result),
|
||||
can_create_category: result.category_list.can_create_category,
|
||||
|
||||
@@ -54,7 +54,7 @@ export default RouteTemplate(
|
||||
{{/if}}
|
||||
|
||||
<CategoriesDisplay
|
||||
@categories={{@controller.model.categories}}
|
||||
@categories={{@controller.model.content}}
|
||||
@topics={{@controller.model.topics}}
|
||||
@parentCategory={{@controller.model.parentCategory}}
|
||||
@loadMore={{@controller.model.loadMore}}
|
||||
@@ -66,7 +66,7 @@ export default RouteTemplate(
|
||||
@name="below-discovery-categories"
|
||||
@connectorTagName="div"
|
||||
@outletArgs={{lazyHash
|
||||
categories=@controller.model.categories
|
||||
categories=@controller.model.content
|
||||
topics=@controller.model.topics
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -37,9 +37,9 @@ export default RouteTemplate(
|
||||
</:navigation>
|
||||
|
||||
<:header>
|
||||
{{#if @controller.model.subcategoryList}}
|
||||
{{#if @controller.model.subcategoryList.content}}
|
||||
<CategoriesDisplay
|
||||
@categories={{@controller.model.subcategoryList.categories}}
|
||||
@categories={{@controller.model.subcategoryList.content}}
|
||||
@parentCategory={{@controller.model.subcategoryList.parentCategory}}
|
||||
@loadMore={{@controller.model.subcategoryList.loadMore}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, settled } from "@ember/test-helpers";
|
||||
import { setupRenderingTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import CategoryList from "discourse/models/category-list";
|
||||
|
||||
module("Integration | Component | CategoryList", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("UI updates when CategoryList array and properties change", async function (assert) {
|
||||
const categories = [
|
||||
{ id: 1, name: "Cat 1" },
|
||||
{ id: 2, name: "Cat 2" },
|
||||
];
|
||||
this.categoryList = CategoryList.create({ categories });
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div data-test-category-list>
|
||||
<ul>
|
||||
{{#each this.categoryList.content as |cat|}}
|
||||
<li data-test-category>{{cat.name}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div data-test-page>Page: {{this.categoryList.page}}</div>
|
||||
<div data-test-fetched-last-page>Fetched Last Page:
|
||||
{{this.categoryList.fetchedLastPage}}</div>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom("[data-test-category]")
|
||||
.exists({ count: 2 }, "renders initial categories");
|
||||
assert.dom("[data-test-category]:nth-child(1)").hasText("Cat 1");
|
||||
assert.dom("[data-test-category]:nth-child(2)").hasText("Cat 2");
|
||||
|
||||
// Add a category (mutate .content, not the proxy)
|
||||
this.categoryList.content.push({ id: 3, name: "Cat 3" });
|
||||
await settled();
|
||||
assert
|
||||
.dom("[data-test-category]")
|
||||
.exists({ count: 3 }, "renders after adding category");
|
||||
assert.dom("[data-test-category]:nth-child(3)").hasText("Cat 3");
|
||||
|
||||
// Remove a category
|
||||
this.categoryList.content.splice(0, 1);
|
||||
await settled();
|
||||
assert
|
||||
.dom("[data-test-category]")
|
||||
.exists({ count: 2 }, "renders after removing category");
|
||||
assert.dom("[data-test-category]:nth-child(1)").hasText("Cat 2");
|
||||
|
||||
// Change page property
|
||||
this.categoryList.page = 2;
|
||||
await settled();
|
||||
assert.dom("[data-test-page]").hasText("Page: 2");
|
||||
|
||||
// Change fetchedLastPage property
|
||||
this.categoryList.fetchedLastPage = true;
|
||||
await settled();
|
||||
assert
|
||||
.dom("[data-test-fetched-last-page]")
|
||||
.hasText("Fetched Last Page: true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import { withSilencedDeprecations } from "discourse/lib/deprecated";
|
||||
import LegacyArrayLikeObject from "discourse/lib/legacy-array-like-object";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
|
||||
module("Unit | lib | LegacyArrayLikeObject", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test("constructs with default values", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const obj = LegacyArrayLikeObject.create();
|
||||
assert.true(
|
||||
obj instanceof LegacyArrayLikeObject,
|
||||
"returns an LegacyArrayLikeObject instance"
|
||||
);
|
||||
assert.strictEqual(obj.length, 0, "empty by default");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts initial items", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const obj = LegacyArrayLikeObject.create({ content: [1, 2, 3] });
|
||||
assert.deepEqual([...obj], [1, 2, 3], "contains initial items");
|
||||
assert.strictEqual(obj.length, 3, "length is correct");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts TrackedArray as items", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const arr = new TrackedArray([4, 5]);
|
||||
const obj = LegacyArrayLikeObject.create({ content: arr });
|
||||
assert.strictEqual(obj[0], 4);
|
||||
assert.strictEqual(obj[1], 5);
|
||||
assert.strictEqual(obj.length, 2);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("assigns custom properties", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const obj = LegacyArrayLikeObject.create({ content: [1], foo: "bar" });
|
||||
assert.strictEqual(obj.foo, "bar", "property is assigned");
|
||||
obj.foo = "baz";
|
||||
assert.strictEqual(obj.foo, "baz", "property is settable");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("throws if content is not an array", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
assert.throws(
|
||||
() => LegacyArrayLikeObject.create({ content: "not an array" }),
|
||||
/must be an array/
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("array methods work", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
let obj = LegacyArrayLikeObject.create({ content: [1, 2] });
|
||||
obj.push(3);
|
||||
assert.deepEqual([...obj], [1, 2, 3]);
|
||||
assert.strictEqual(obj.pop(), 3);
|
||||
assert.deepEqual([...obj], [1, 2]);
|
||||
obj = LegacyArrayLikeObject.create({ content: [1, 2, 3, 4] });
|
||||
assert.deepEqual(
|
||||
obj.map((x) => x * 2),
|
||||
[2, 4, 6, 8],
|
||||
"map works"
|
||||
);
|
||||
assert.deepEqual(
|
||||
obj.filter((x) => x % 2 === 0),
|
||||
[2, 4],
|
||||
"filter works"
|
||||
);
|
||||
assert.strictEqual(
|
||||
obj.find((x) => x > 2),
|
||||
3,
|
||||
"find works"
|
||||
);
|
||||
assert.strictEqual(
|
||||
obj.findIndex((x) => x === 3),
|
||||
2,
|
||||
"findIndex works"
|
||||
);
|
||||
assert.true(
|
||||
obj.some((x) => x === 2),
|
||||
"some works"
|
||||
);
|
||||
assert.true(
|
||||
obj.every((x) => x > 0),
|
||||
"every works"
|
||||
);
|
||||
assert.false(
|
||||
obj.every((x) => x > 2),
|
||||
"every works for false"
|
||||
);
|
||||
assert.strictEqual(
|
||||
obj.reduce((a, b) => a + b, 0),
|
||||
10,
|
||||
"reduce works"
|
||||
);
|
||||
assert.deepEqual(obj.slice(1, 3), [2, 3], "slice works");
|
||||
assert.deepEqual(
|
||||
obj.concat([5, 6]),
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
"concat works"
|
||||
);
|
||||
assert.deepEqual(obj.slice().reverse(), [4, 3, 2, 1], "reverse works");
|
||||
assert.true(obj.includes(3), "includes works");
|
||||
assert.false(obj.includes(99), "includes works for false");
|
||||
assert.strictEqual(obj.at(0), 1, "at(0) works");
|
||||
assert.strictEqual(obj.at(-1), 4, "at(-1) works");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("instance properties take precedence over array", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
class CustomArrayLike extends LegacyArrayLikeObject {
|
||||
get first() {
|
||||
return "custom";
|
||||
}
|
||||
}
|
||||
const obj = CustomArrayLike.create({ content: [1, 2] });
|
||||
assert.strictEqual(obj.first, "custom");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("subclassing works", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
class CustomArrayLike extends LegacyArrayLikeObject {
|
||||
customField = "foo";
|
||||
#bar = 42;
|
||||
|
||||
get bar() {
|
||||
return this.#bar;
|
||||
}
|
||||
|
||||
set bar(val) {
|
||||
this.#bar = val;
|
||||
}
|
||||
|
||||
get firstPlusBar() {
|
||||
return (this[0] || 0) + this.bar;
|
||||
}
|
||||
|
||||
customMethod() {
|
||||
return this.length * 10;
|
||||
}
|
||||
}
|
||||
const obj = CustomArrayLike.create({ content: [5, 6, 7] });
|
||||
assert.strictEqual(obj.customField, "foo", "custom field is present");
|
||||
assert.strictEqual(obj.bar, 42, "getter works");
|
||||
obj.bar = 100;
|
||||
assert.strictEqual(obj.bar, 100, "setter works");
|
||||
assert.strictEqual(obj.customMethod(), 30, "custom method works");
|
||||
assert.strictEqual(
|
||||
obj.firstPlusBar,
|
||||
105,
|
||||
"getter using array and field works"
|
||||
);
|
||||
assert.strictEqual(obj.length, 3, "length is correct");
|
||||
assert.strictEqual(obj[0], 5, "index access works");
|
||||
obj.push(8);
|
||||
assert.deepEqual([...obj], [5, 6, 7, 8], "push works");
|
||||
assert.strictEqual(obj.pop(), 8, "pop works");
|
||||
assert.deepEqual(
|
||||
obj.map((x) => x * 2),
|
||||
[10, 12, 14],
|
||||
"map works"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("multiple levels of inheritance work as expected", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
class BaseArrayLike extends LegacyArrayLikeObject {
|
||||
baseField = "base";
|
||||
|
||||
get baseValue() {
|
||||
return this.baseField + this.length;
|
||||
}
|
||||
}
|
||||
|
||||
class MidArrayLike extends BaseArrayLike {
|
||||
midField = "mid";
|
||||
|
||||
get midValue() {
|
||||
return this.midField + (this[0] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
class FinalArrayLike extends MidArrayLike {
|
||||
finalField = "final";
|
||||
|
||||
get finalValue() {
|
||||
return this.finalField + (this[1] || 0);
|
||||
}
|
||||
|
||||
customMethod() {
|
||||
return this.baseValue + this.midValue + this.finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
const obj = FinalArrayLike.create({ content: [10, 20] });
|
||||
assert.strictEqual(obj.baseField, "base", "base field present");
|
||||
assert.strictEqual(obj.baseValue, "base2", "base getter works");
|
||||
assert.strictEqual(obj.midField, "mid", "mid field present");
|
||||
assert.strictEqual(obj.midValue, "mid10", "mid getter works");
|
||||
assert.strictEqual(obj.finalField, "final", "final field present");
|
||||
assert.strictEqual(obj.finalValue, "final20", "final getter works");
|
||||
assert.strictEqual(
|
||||
obj.customMethod(),
|
||||
"base2mid10final20",
|
||||
"custom method combines all levels"
|
||||
);
|
||||
assert.strictEqual(obj.length, 2, "length is correct");
|
||||
assert.strictEqual(obj[0], 10, "index access works");
|
||||
obj.push(30);
|
||||
assert.deepEqual([...obj], [10, 20, 30], "push works");
|
||||
assert.strictEqual(obj.pop(), 30, "pop works");
|
||||
assert.deepEqual(
|
||||
obj.map((x) => x + 1),
|
||||
[11, 21],
|
||||
"map works"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("pluginApi.modifyClass works", function (assert) {
|
||||
class BaseArrayLike extends LegacyArrayLikeObject {
|
||||
baseField = "base";
|
||||
|
||||
get baseValue() {
|
||||
return this.baseField + this.content.length;
|
||||
}
|
||||
}
|
||||
class MidArrayLike extends BaseArrayLike {
|
||||
midField = "mid";
|
||||
|
||||
get midValue() {
|
||||
return this.midField + (this.content[0] || 0);
|
||||
}
|
||||
}
|
||||
class FinalArrayLike extends MidArrayLike {
|
||||
finalField = "final";
|
||||
_value = "initial";
|
||||
|
||||
get valueGetter() {
|
||||
return "original";
|
||||
}
|
||||
|
||||
get finalValue() {
|
||||
return this.finalField + (this.content[1] || 0);
|
||||
}
|
||||
|
||||
customMethod() {
|
||||
return this.baseValue + this.midValue + this.finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
getOwner(this).register("final-array-like:main", FinalArrayLike);
|
||||
|
||||
// Plugin API modifies the class
|
||||
withPluginApi((api) => {
|
||||
api.modifyClass(
|
||||
"final-array-like:main",
|
||||
(Superclass) =>
|
||||
class extends Superclass {
|
||||
// overriding getter from a base class works
|
||||
get valueGetter() {
|
||||
return super.valueGetter + " was modified";
|
||||
}
|
||||
|
||||
get pluginValue() {
|
||||
return (this.content[2] || 0) + this._value;
|
||||
}
|
||||
|
||||
set pluginValue(val) {
|
||||
this._value = val;
|
||||
}
|
||||
|
||||
get customSetterValue() {
|
||||
return this._customSetterValue;
|
||||
}
|
||||
|
||||
set customSetterValue(val) {
|
||||
this._customSetterValue = val * 2;
|
||||
}
|
||||
|
||||
customMethod() {
|
||||
return (
|
||||
"plugin-" + this.baseValue + this.midValue + this.finalValue
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const obj = getOwner(this).lookup("final-array-like:main");
|
||||
obj.content.push(10, 20, 30);
|
||||
|
||||
// The original inheritance chain still works
|
||||
assert.strictEqual(obj.baseField, "base", "base field present");
|
||||
assert.strictEqual(obj.baseValue, "base3", "base getter works");
|
||||
assert.strictEqual(obj.midField, "mid", "mid field present");
|
||||
assert.strictEqual(obj.midValue, "mid10", "mid getter works");
|
||||
assert.strictEqual(obj.finalField, "final", "final field present");
|
||||
assert.strictEqual(obj.finalValue, "final20", "final getter works");
|
||||
|
||||
// Plugin modifications
|
||||
assert.strictEqual(obj.pluginValue, "30initial", "plugin getter works");
|
||||
obj.pluginValue = "changed";
|
||||
assert.strictEqual(obj.pluginValue, "30changed", "plugin setter works");
|
||||
assert.strictEqual(
|
||||
obj._value,
|
||||
"changed",
|
||||
"plugin setter sets backing field"
|
||||
);
|
||||
obj.customSetterValue = 5;
|
||||
assert.strictEqual(
|
||||
obj.customSetterValue,
|
||||
10,
|
||||
"customSetterValue setter works"
|
||||
);
|
||||
assert.strictEqual(
|
||||
obj._customSetterValue,
|
||||
10,
|
||||
"customSetterValue setter sets backing field"
|
||||
);
|
||||
assert.strictEqual(
|
||||
obj.customSetterValue,
|
||||
10,
|
||||
"customSetterValue getter works"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
obj.valueGetter,
|
||||
"original was modified",
|
||||
"plugin getter overrides original"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
obj.customMethod(),
|
||||
"plugin-base3mid10final20",
|
||||
"plugin method overrides original"
|
||||
);
|
||||
|
||||
// Array-like behavior
|
||||
assert.strictEqual(obj.content.length, 3, "length is correct");
|
||||
assert.strictEqual(obj.content[0], 10, "index access works");
|
||||
obj.content.push(40);
|
||||
assert.deepEqual([...obj.content], [10, 20, 30, 40], "push works");
|
||||
assert.strictEqual(obj.content.pop(), 40, "pop works");
|
||||
assert.deepEqual(
|
||||
obj.content.map((x) => x + 1),
|
||||
[11, 21, 31],
|
||||
"map works"
|
||||
);
|
||||
});
|
||||
|
||||
test("Array.isArray checks", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const obj = LegacyArrayLikeObject.create([1, 2, 3]);
|
||||
assert.true(
|
||||
Array.isArray(obj),
|
||||
"LegacyArrayLikeObject is considered an array"
|
||||
);
|
||||
assert.true(Array.isArray([...obj]), "spread result is a true array");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("spread, for..of, for..in iteration", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const obj = LegacyArrayLikeObject.create([10, 20, 30]);
|
||||
assert.deepEqual([...obj], [10, 20, 30], "array spread works");
|
||||
const values = [];
|
||||
for (const v of obj) {
|
||||
values.push(v);
|
||||
}
|
||||
assert.deepEqual(values, [10, 20, 30], "for..of works");
|
||||
const keys = [];
|
||||
for (const k in obj) {
|
||||
if (!isNaN(Number(k))) {
|
||||
keys.push(Number(k));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(keys, [0, 1, 2], "for..in yields array indices");
|
||||
obj.foo = "bar";
|
||||
const props = [];
|
||||
for (const k in obj) {
|
||||
if (obj.hasOwnProperty(k)) {
|
||||
props.push(k);
|
||||
}
|
||||
}
|
||||
assert.true(props.includes("foo"), "for..in yields custom properties");
|
||||
class SubArrayLike extends LegacyArrayLikeObject {
|
||||
custom = true;
|
||||
}
|
||||
const sub = SubArrayLike.create({ content: [1, 2] });
|
||||
const subKeys = [];
|
||||
for (const k in sub) {
|
||||
if (!sub.hasOwnProperty(k)) {
|
||||
continue;
|
||||
}
|
||||
subKeys.push(k);
|
||||
}
|
||||
assert.true(
|
||||
subKeys.includes("custom"),
|
||||
"for..in yields subclass properties"
|
||||
);
|
||||
const empty = LegacyArrayLikeObject.create();
|
||||
assert.deepEqual([...empty], [], "spread works for empty");
|
||||
const emptyVals = [];
|
||||
for (const v of empty) {
|
||||
emptyVals.push(v);
|
||||
}
|
||||
assert.deepEqual(emptyVals, [], "for..of works for empty");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("object spread operator", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const obj = LegacyArrayLikeObject.create([1, 2, 3]);
|
||||
let spread = { ...obj };
|
||||
assert.deepEqual(
|
||||
Object.keys(spread),
|
||||
["0", "1", "2"],
|
||||
"array indices are enumerable own properties by default"
|
||||
);
|
||||
assert.deepEqual(
|
||||
spread,
|
||||
{ 0: 1, 1: 2, 2: 3 },
|
||||
"spread result contains array elements by default"
|
||||
);
|
||||
obj.foo = "bar";
|
||||
spread = { ...obj };
|
||||
assert.true(spread.hasOwnProperty("foo"), "custom property is present");
|
||||
assert.strictEqual(
|
||||
spread.foo,
|
||||
"bar",
|
||||
"custom property value is correct"
|
||||
);
|
||||
assert.strictEqual(spread[0], 1, "array element still present");
|
||||
obj[0] = 99;
|
||||
spread = { ...obj };
|
||||
assert.true(
|
||||
spread.hasOwnProperty("0"),
|
||||
"numeric property is present if own"
|
||||
);
|
||||
assert.strictEqual(
|
||||
spread[0],
|
||||
99,
|
||||
"numeric property value overrides array element"
|
||||
);
|
||||
class SubArrayLike extends LegacyArrayLikeObject {
|
||||
custom = 42;
|
||||
}
|
||||
const sub = SubArrayLike.create({ content: [5, 6] });
|
||||
let subSpread = { ...sub };
|
||||
assert.true(
|
||||
subSpread.hasOwnProperty("custom"),
|
||||
"subclass own property is present"
|
||||
);
|
||||
assert.strictEqual(
|
||||
subSpread.custom,
|
||||
42,
|
||||
"subclass property value is correct"
|
||||
);
|
||||
assert.strictEqual(subSpread[0], 5, "subclass array element present");
|
||||
const empty = LegacyArrayLikeObject.create();
|
||||
const emptySpread = { ...empty };
|
||||
assert.deepEqual(emptySpread, {}, "spread of empty object is empty");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("content property returns underlying array", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const arr = [1, 2, 3];
|
||||
const obj = LegacyArrayLikeObject.create(arr);
|
||||
assert.deepEqual(
|
||||
obj.content,
|
||||
arr,
|
||||
"content property matches input array"
|
||||
);
|
||||
obj.push(4);
|
||||
assert.deepEqual(
|
||||
obj.content,
|
||||
[1, 2, 3, 4],
|
||||
"content property updates after mutation"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor is private and cannot be called directly", function (assert) {
|
||||
assert.throws(() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new LegacyArrayLikeObject([1, 2, 3]);
|
||||
}, /private constructor|is not a constructor|TypeError/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,489 @@
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import { withSilencedDeprecations } from "discourse/lib/deprecated";
|
||||
import PreloadStore from "discourse/lib/preload-store";
|
||||
import CategoryList from "discourse/models/category-list";
|
||||
import Site from "discourse/models/site";
|
||||
import Topic from "discourse/models/topic";
|
||||
import pretender, { response } from "discourse/tests/helpers/create-pretender";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
module("Unit | Model | CategoryList", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.store = getOwner(this).lookup("service:store");
|
||||
});
|
||||
|
||||
test("categoriesFrom creates categories from API result", function (assert) {
|
||||
const result = {
|
||||
category_list: {
|
||||
categories: [
|
||||
{
|
||||
id: 1,
|
||||
name: "General",
|
||||
topics_week: 5,
|
||||
topics_month: 20,
|
||||
topics_all_time: 100,
|
||||
parent_category_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Support",
|
||||
topics_week: 0,
|
||||
topics_month: 3,
|
||||
topics_all_time: 50,
|
||||
parent_category_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const categoryList = CategoryList.categoriesFrom(this.store, result);
|
||||
|
||||
assert.true(categoryList instanceof CategoryList);
|
||||
assert.strictEqual(
|
||||
categoryList.content.length,
|
||||
2,
|
||||
".content provides clean access to the array"
|
||||
);
|
||||
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
assert.strictEqual(categoryList.length, 2, "proxy length works");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("categoriesFrom filters categories by parent category", function (assert) {
|
||||
const parentCategory = { id: 1 };
|
||||
const result = {
|
||||
category_list: {
|
||||
categories: [
|
||||
{
|
||||
id: 2,
|
||||
name: "Child Category",
|
||||
parent_category_id: 1,
|
||||
topics_all_time: 10,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Other Category",
|
||||
parent_category_id: 2,
|
||||
topics_all_time: 15,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const categoryList = CategoryList.categoriesFrom(
|
||||
this.store,
|
||||
result,
|
||||
parentCategory
|
||||
);
|
||||
|
||||
assert.strictEqual(categoryList.content.length, 1);
|
||||
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
assert.strictEqual(categoryList.length, 1, "proxy length works");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("categoriesFrom handles empty category list", function (assert) {
|
||||
const result = { category_list: { categories: [] } };
|
||||
const categoryList = CategoryList.categoriesFrom(this.store, result);
|
||||
|
||||
assert.true(categoryList instanceof CategoryList);
|
||||
assert.strictEqual(categoryList.content.length, 0);
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
assert.strictEqual(categoryList.length, 0, "proxy length works");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("array methods on .content work and do not warn", function (assert) {
|
||||
const categoryList = CategoryList.create({ categories: [] });
|
||||
categoryList.content.push({ id: 1 });
|
||||
assert.strictEqual(categoryList.content.length, 1);
|
||||
categoryList.content.splice(0, 1);
|
||||
assert.strictEqual(categoryList.content.length, 0);
|
||||
});
|
||||
|
||||
test("_buildCategoryResult builds category with week stats", function (assert) {
|
||||
const rawData = {
|
||||
id: 1,
|
||||
topics_week: 10,
|
||||
topics_month: 20,
|
||||
topics_all_time: 100,
|
||||
};
|
||||
|
||||
const result = CategoryList._buildCategoryResult(rawData, "week");
|
||||
|
||||
assert.true(result.stat.includes("10"));
|
||||
assert.true(result.stat.includes("value"));
|
||||
assert.strictEqual(
|
||||
result.statTitle,
|
||||
i18n(`categories.topic_stat_sentence_week`, {
|
||||
count: rawData.topics_week,
|
||||
})
|
||||
);
|
||||
assert.false(result.pickAll);
|
||||
});
|
||||
|
||||
test("_buildCategoryResult builds category with all-time stats when no recent activity", function (assert) {
|
||||
const rawData = {
|
||||
id: 1,
|
||||
topics_week: 0,
|
||||
topics_month: 0,
|
||||
topics_all_time: 50,
|
||||
};
|
||||
|
||||
const result = CategoryList._buildCategoryResult(rawData, "week");
|
||||
|
||||
assert.true(result.stat.includes("50"));
|
||||
assert.true(result.pickAll);
|
||||
});
|
||||
|
||||
test("_buildCategoryResult processes topics array", function (assert) {
|
||||
const rawData = {
|
||||
id: 1,
|
||||
topics: [{ id: 1, title: "Test Topic" }],
|
||||
topics_all_time: 10,
|
||||
};
|
||||
|
||||
const result = CategoryList._buildCategoryResult(rawData, "all");
|
||||
|
||||
assert.strictEqual(result.topics.length, 1);
|
||||
assert.true(result.topics[0] instanceof Topic);
|
||||
});
|
||||
|
||||
test("_buildCategoryResult adds mobile stats", function (assert) {
|
||||
const originalSiteCurrent = Site.current;
|
||||
Site.current = () => ({
|
||||
updateCategory: (category) => {
|
||||
category.setupGroupsAndPermissions = () => {};
|
||||
return category;
|
||||
},
|
||||
mobileView: true,
|
||||
});
|
||||
|
||||
const rawData = {
|
||||
id: 1,
|
||||
topics_all_time: 100,
|
||||
};
|
||||
|
||||
const result = CategoryList._buildCategoryResult(rawData, "all");
|
||||
|
||||
assert.true(result.statTotal.includes("100"));
|
||||
|
||||
Site.current = originalSiteCurrent;
|
||||
});
|
||||
|
||||
test("list fetches and clears categories from PreloadStore", async function (assert) {
|
||||
const mockResult = {
|
||||
category_list: {
|
||||
categories: [{ id: 1, name: "Test", topics_all_time: 10 }],
|
||||
can_create_category: true,
|
||||
can_create_topic: true,
|
||||
},
|
||||
};
|
||||
// Store the mock result in PreloadStore under the correct key
|
||||
PreloadStore.store("categories_list", mockResult);
|
||||
|
||||
// Spy on AJAX to ensure it is NOT called
|
||||
let ajaxCalled = false;
|
||||
pretender.get("/categories.json", () => {
|
||||
ajaxCalled = true;
|
||||
return response({});
|
||||
});
|
||||
|
||||
const categoryList = await CategoryList.list(this.store);
|
||||
|
||||
assert.true(categoryList instanceof CategoryList, "Returns a CategoryList");
|
||||
assert.true(categoryList.can_create_category, "can_create_category is set");
|
||||
assert.true(categoryList.can_create_topic, "can_create_topic is set");
|
||||
assert.false(
|
||||
ajaxCalled,
|
||||
"AJAX should not be called if PreloadStore is used"
|
||||
);
|
||||
assert.strictEqual(
|
||||
PreloadStore.get("categories_list"),
|
||||
undefined,
|
||||
"PreloadStore key is cleared after use"
|
||||
);
|
||||
});
|
||||
|
||||
test("list includes parent category ID in request", async function (assert) {
|
||||
const parentCategory = { id: 5 };
|
||||
let requestData = {};
|
||||
|
||||
pretender.get("/categories.json", (request) => {
|
||||
requestData = request.queryParams;
|
||||
return response({
|
||||
category_list: { categories: [] },
|
||||
});
|
||||
});
|
||||
|
||||
await CategoryList.list(this.store, parentCategory);
|
||||
|
||||
assert.strictEqual(parseInt(requestData.parent_category_id, 10), 5);
|
||||
});
|
||||
|
||||
test(".create creates new CategoryList instance", function (assert) {
|
||||
const attrs = {
|
||||
categories: [],
|
||||
can_create_category: true,
|
||||
};
|
||||
|
||||
const categoryList = CategoryList.create(attrs);
|
||||
|
||||
assert.true(categoryList instanceof CategoryList);
|
||||
});
|
||||
|
||||
test("loadMore loads more categories successfully", async function (assert) {
|
||||
let requestData = {};
|
||||
|
||||
pretender.get("/categories.json", (request) => {
|
||||
requestData = request.queryParams;
|
||||
return response({
|
||||
category_list: {
|
||||
categories: [{ id: 2, name: "New Category", topics_all_time: 5 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const categoryList = CategoryList.create({
|
||||
categories: [],
|
||||
store: this.store,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
await categoryList.loadMore();
|
||||
|
||||
assert.strictEqual(categoryList.page, 2);
|
||||
assert.false(categoryList.isLoading);
|
||||
assert.strictEqual(parseInt(requestData.page, 10), 2);
|
||||
});
|
||||
|
||||
test("loadMore sets fetchedLastPage when no more categories", async function (assert) {
|
||||
pretender.get("/categories.json", () => {
|
||||
return response({
|
||||
category_list: { categories: [] },
|
||||
});
|
||||
});
|
||||
|
||||
const categoryList = CategoryList.create({
|
||||
categories: [],
|
||||
store: this.store,
|
||||
});
|
||||
|
||||
await categoryList.loadMore();
|
||||
|
||||
assert.true(categoryList.fetchedLastPage);
|
||||
});
|
||||
|
||||
test("loadMore includes parent category ID in request", async function (assert) {
|
||||
const parentCategory = { id: 3 };
|
||||
let requestData = {};
|
||||
|
||||
pretender.get("/categories.json", (request) => {
|
||||
requestData = request.queryParams;
|
||||
return response({
|
||||
category_list: { categories: [] },
|
||||
});
|
||||
});
|
||||
|
||||
const categoryList = CategoryList.create({
|
||||
categories: [],
|
||||
store: this.store,
|
||||
parentCategory,
|
||||
});
|
||||
|
||||
await categoryList.loadMore();
|
||||
|
||||
assert.strictEqual(parseInt(requestData.page, 10), 2);
|
||||
assert.strictEqual(parseInt(requestData.parent_category_id, 10), 3);
|
||||
});
|
||||
|
||||
test("loadMore does not load when already loading", async function (assert) {
|
||||
let ajaxCalled = false;
|
||||
|
||||
pretender.get("/categories.json", () => {
|
||||
ajaxCalled = true;
|
||||
return response({});
|
||||
});
|
||||
|
||||
const categoryList = CategoryList.create({
|
||||
categories: [],
|
||||
store: this.store,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
await categoryList.loadMore();
|
||||
|
||||
assert.false(ajaxCalled, "Ajax should not be called");
|
||||
});
|
||||
|
||||
test("loadMore does not load when last page is fetched", async function (assert) {
|
||||
let ajaxCalled = false;
|
||||
|
||||
pretender.get("/categories.json", () => {
|
||||
ajaxCalled = true;
|
||||
return response({});
|
||||
});
|
||||
|
||||
const categoryList = CategoryList.create({
|
||||
categories: [],
|
||||
store: this.store,
|
||||
fetchedLastPage: true,
|
||||
});
|
||||
|
||||
await categoryList.loadMore();
|
||||
|
||||
assert.false(ajaxCalled, "Ajax should not be called");
|
||||
});
|
||||
|
||||
test("loadMore handles ajax error gracefully", async function (assert) {
|
||||
pretender.get("/categories.json", () => {
|
||||
return response(500, { errors: ["Network error"] });
|
||||
});
|
||||
|
||||
const categoryList = CategoryList.create({
|
||||
categories: [],
|
||||
store: this.store,
|
||||
});
|
||||
|
||||
try {
|
||||
await categoryList.loadMore();
|
||||
} catch {
|
||||
// Error should be thrown but loading state should be reset
|
||||
}
|
||||
|
||||
assert.false(categoryList.isLoading);
|
||||
});
|
||||
|
||||
test("CategoryList behaves like an array", function (assert) {
|
||||
withSilencedDeprecations(
|
||||
"discourse.legacy-array-like-object.proxied-array",
|
||||
() => {
|
||||
const categories = [
|
||||
{ id: 1, name: "Cat 1", topics_all_time: 10 },
|
||||
{ id: 2, name: "Cat 2", topics_all_time: 20 },
|
||||
{ id: 3, name: "Cat 3", topics_all_time: 30 },
|
||||
];
|
||||
const list = CategoryList.create({ categories });
|
||||
|
||||
// list[0] returns the first element
|
||||
assert.strictEqual(list[0].id, 1, "list[0] returns first category");
|
||||
|
||||
// list.length returns correct length
|
||||
assert.strictEqual(
|
||||
list.length,
|
||||
3,
|
||||
"list.length returns correct length"
|
||||
);
|
||||
|
||||
// forEach works
|
||||
let ids = [];
|
||||
list.forEach((cat) => ids.push(cat.id));
|
||||
assert.deepEqual(
|
||||
ids,
|
||||
[1, 2, 3],
|
||||
"forEach iterates over all categories"
|
||||
);
|
||||
|
||||
// map works
|
||||
const names = list.map((cat) => cat.name);
|
||||
assert.deepEqual(
|
||||
names,
|
||||
["Cat 1", "Cat 2", "Cat 3"],
|
||||
"map returns names"
|
||||
);
|
||||
|
||||
// filter works
|
||||
const filtered = list.filter((cat) => cat.topics_all_time > 10);
|
||||
assert.strictEqual(filtered.length, 2, "filter returns correct number");
|
||||
assert.strictEqual(
|
||||
filtered[0].id,
|
||||
2,
|
||||
"filter returns correct category"
|
||||
);
|
||||
|
||||
// find works
|
||||
const found = list.find((cat) => cat.id === 2);
|
||||
assert.strictEqual(
|
||||
found.name,
|
||||
"Cat 2",
|
||||
"find returns correct category"
|
||||
);
|
||||
|
||||
// findIndex works
|
||||
const foundIdx = list.findIndex((cat) => cat.id === 3);
|
||||
assert.strictEqual(foundIdx, 2, "findIndex returns correct index");
|
||||
|
||||
// some works
|
||||
assert.true(
|
||||
list.some((cat) => cat.topics_all_time === 20),
|
||||
"some returns true if any match"
|
||||
);
|
||||
|
||||
// every works
|
||||
assert.true(
|
||||
list.every((cat) => cat.id > 0),
|
||||
"every returns true if all match"
|
||||
);
|
||||
assert.false(
|
||||
list.every((cat) => cat.topics_all_time > 10),
|
||||
"every returns false if not all match"
|
||||
);
|
||||
|
||||
// reduce works
|
||||
const totalTopics = list.reduce(
|
||||
(sum, cat) => sum + cat.topics_all_time,
|
||||
0
|
||||
);
|
||||
assert.strictEqual(totalTopics, 60, "reduce sums topics_all_time");
|
||||
|
||||
// slice works
|
||||
const sliced = list.slice(1);
|
||||
assert.strictEqual(sliced.length, 2, "slice returns correct length");
|
||||
assert.strictEqual(sliced[0].id, 2, "slice returns correct element");
|
||||
|
||||
// concat works
|
||||
const extra = { id: 4, name: "Cat 4", topics_all_time: 40 };
|
||||
const combined = list.concat([extra]);
|
||||
assert.strictEqual(combined.length, 4, "concat returns correct length");
|
||||
assert.strictEqual(combined[3].id, 4, "concat returns correct element");
|
||||
|
||||
// reverse works
|
||||
const reversed = list.slice().reverse();
|
||||
assert.deepEqual(
|
||||
reversed.map((cat) => cat.id),
|
||||
[3, 2, 1],
|
||||
"reverse returns reversed array"
|
||||
);
|
||||
|
||||
// includes works
|
||||
assert.true(
|
||||
list.includes(list[1]),
|
||||
"includes returns true for contained element"
|
||||
);
|
||||
assert.false(
|
||||
list.includes({ id: 99 }),
|
||||
"includes returns false for non-contained element"
|
||||
);
|
||||
|
||||
// at works
|
||||
assert.strictEqual(list.at(0).id, 1, "at(0) returns first element");
|
||||
assert.strictEqual(list.at(-1).id, 3, "at(-1) returns last element");
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user