DEV: implements category-row as a glimmer template (#25223)

This code introduces code duplication but category dropdown are a common performance area and we expect that having category rows as a glimmer template will improve performance when hundred of rows are rendered.

In the future we should investigate creating a base select-row component using glimmer to ease the migration.
This commit is contained in:
Joffrey JAFFEUX 2024-01-11 14:00:04 +01:00 committed by GitHub
parent 6d9fcf8f76
commit 63a50b12fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 321 additions and 143 deletions

View File

@ -6,6 +6,7 @@ import { setting } from "discourse/lib/computed";
import Category from "discourse/models/category";
import PermissionType from "discourse/models/permission-type";
import I18n from "discourse-i18n";
import CategoryRow from "select-kit/components/category-row";
import ComboBoxComponent from "select-kit/components/combo-box";
export default ComboBoxComponent.extend({
@ -40,7 +41,7 @@ export default ComboBoxComponent.extend({
},
modifyComponentForRow() {
return "category-row";
return CategoryRow;
},
modifyNoSelection() {

View File

@ -8,6 +8,7 @@ import DiscourseURL, {
} from "discourse/lib/url";
import Category from "discourse/models/category";
import I18n from "discourse-i18n";
import CategoryRow from "select-kit/components/category-row";
import ComboBoxComponent from "select-kit/components/combo-box";
export const NO_CATEGORIES_ID = "no-categories";
@ -41,7 +42,7 @@ export default ComboBoxComponent.extend({
},
modifyComponentForRow() {
return "category-row";
return CategoryRow;
},
displayCategoryDescription: computed(function () {

View File

@ -0,0 +1,309 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { isEmpty, isNone } from "@ember/utils";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import concatClass from "discourse/helpers/concat-class";
import dirSpan from "discourse/helpers/dir-span";
import Category from "discourse/models/category";
export default class CategoryRow extends Component {
@service site;
@service siteSettings;
get isNone() {
return this.rowValue === this.args.selectKit?.noneItem;
}
get highlightedValue() {
return this.args.selectKit.get("highlighted.id");
}
get isHighlighted() {
return this.rowValue === this.highlightedValue;
}
get isSelected() {
return this.rowValue === this.args.value;
}
get hideParentCategory() {
return this.args.selectKit.options.hideParentCategory;
}
get categoryLink() {
return this.args.selectKit.options.categoryLink;
}
get countSubcategories() {
return this.args.selectKit.options.countSubcategories;
}
get allowUncategorizedTopics() {
return this.siteSettings.hideParentCategory;
}
get allowUncategorized() {
return this.args.selectKit.options.allowUncategorized;
}
get rowName() {
return this.args.item?.name;
}
get rowValue() {
return this.args.item?.id;
}
get guid() {
return guidFor(this.args.item);
}
get label() {
return this.args.item?.name;
}
get displayCategoryDescription() {
const option = this.args.selectKit.options.displayCategoryDescription;
if (isNone(option)) {
return true;
}
return option;
}
get title() {
if (this.category) {
return this.categoryName;
}
}
get categoryName() {
return this.category.name;
}
get categoryDescriptionText() {
return this.category.description_text;
}
@cached
get category() {
if (isEmpty(this.rowValue)) {
const uncategorized = Category.findUncategorized();
if (uncategorized && uncategorized.name === this.rowName) {
return uncategorized;
}
} else {
return Category.findById(parseInt(this.rowValue, 10));
}
}
@cached
get badgeForCategory() {
return htmlSafe(
categoryBadgeHTML(this.category, {
link: this.categoryLink,
allowUncategorized:
this.allowUncategorizedTopics || this.allowUncategorized,
hideParent: !!this.parentCategory,
topicCount: this.topicCount,
})
);
}
@cached
get badgeForParentCategory() {
return htmlSafe(
categoryBadgeHTML(this.parentCategory, {
link: this.categoryLink,
allowUncategorized:
this.allowUncategorizedTopics || this.allowUncategorized,
recursive: true,
})
);
}
get parentCategory() {
return Category.findById(this.parentCategoryId);
}
get hasParentCategory() {
return this.parentCategoryId;
}
get parentCategoryId() {
return this.category?.parent_category_id;
}
get categoryTotalTopicCount() {
return this.category?.totalTopicCount;
}
get categoryTopicCount() {
return this.category?.topic_count;
}
get topicCount() {
return this.countSubcategories
? this.categoryTotalTopicCount
: this.categoryTopicCount;
}
get shouldDisplayDescription() {
return (
this.displayCategoryDescription &&
this.categoryDescriptionText &&
this.categoryDescriptionText !== "null"
);
}
@cached
get descriptionText() {
if (this.categoryDescriptionText) {
return this._formatDescription(this.categoryDescriptionText);
}
}
@action
handleMouseEnter() {
if (this.site.mobileView) {
return;
}
if (!this.isDestroying || !this.isDestroyed) {
this.args.selectKit.onHover(this.rowValue, this.args.item);
}
return false;
}
@action
handleClick(event) {
event.preventDefault();
event.stopPropagation();
this.args.selectKit.select(this.rowValue, this.args.item);
return false;
}
@action
handleMouseDown(event) {
if (this.args.selectKit.options.preventHeaderFocus) {
event.preventDefault();
}
}
@action
handleFocusIn(event) {
event.stopImmediatePropagation();
}
@action
handleKeyDown(event) {
if (this.args.selectKit.isExpanded) {
if (event.key === "Backspace") {
if (this.args.selectKit.isFilterExpanded) {
this.args.selectKit.set(
"filter",
this.args.selectKit.filter.slice(0, -1)
);
this.args.selectKit.triggerSearch();
this.args.selectKit.focusFilter();
event.preventDefault();
event.stopPropagation();
}
} else if (event.key === "ArrowUp") {
this.args.selectKit.highlightPrevious();
event.preventDefault();
} else if (event.key === "ArrowDown") {
this.args.selectKit.highlightNext();
event.preventDefault();
} else if (event.key === "Enter") {
event.stopImmediatePropagation();
this.args.selectKit.select(
this.args.selectKit.highlighted.id,
this.args.selectKit.highlighted
);
event.preventDefault();
} else if (event.key === "Escape") {
this.args.selectKit.close(event);
this.args.selectKit.headerElement().focus();
event.preventDefault();
event.stopPropagation();
} else {
if (this._isValidInput(event.key)) {
this.args.selectKit.set("filter", event.key);
this.args.selectKit.triggerSearch();
this.args.selectKit.focusFilter();
event.preventDefault();
event.stopPropagation();
}
}
}
}
_formatDescription(description) {
const limit = 200;
return `${description.slice(0, limit)}${
description.length > limit ? "…" : ""
}`;
}
_isValidInput(eventKey) {
// relying on passing the event to the input is risky as it could not work
// dispatching the event won't work as the event won't be trusted
// safest solution is to filter event and prefill filter with it
const nonInputKeysRegex =
/F\d+|Arrow.+|Meta|Alt|Control|Shift|Delete|Enter|Escape|Tab|Space|Insert|Backspace/;
return !nonInputKeysRegex.test(eventKey);
}
<template>
{{! template-lint-disable no-pointer-down-event-binding }}
<div
class={{concatClass
"category-row"
"select-kit-row"
(if this.isSelected "is-selected")
(if this.isHighlighted "is-highlighted")
(if this.isNone "is-none")
}}
role="menuitemradio"
data-index={{@index}}
data-name={{this.rowName}}
data-value={{this.rowValue}}
data-title={{this.title}}
title={{this.title}}
data-guid={{this.guid}}
{{on "focusin" this.handleFocusIn}}
{{on "mousedown" this.handleMouseDown}}
{{on "mouseenter" this.handleMouseEnter passive=true}}
{{on "click" this.handleClick}}
{{on "keydown" this.handleKeyDown}}
aria-checked={{this.isSelected}}
tabindex="0"
>
{{#if this.category}}
<div class="category-status" aria-hidden="true">
{{#if this.hasParentCategory}}
{{#unless this.hideParentCategory}}
{{this.badgeForParentCategory}}
{{/unless}}
{{/if}}
{{this.badgeForCategory}}
</div>
{{#if this.shouldDisplayDescription}}
<div class="category-desc" aria-hidden="true">
{{dirSpan this.descriptionText htmlSafe="true"}}
</div>
{{/if}}
{{else}}
{{htmlSafe this.label}}
{{/if}}
</div>
</template>
}

View File

@ -1,19 +0,0 @@
{{#if this.category}}
<div class="category-status" aria-hidden="true">
{{#if this.hasParentCategory}}
{{#unless this.hideParentCategory}}
{{this.badgeForParentCategory}}
{{/unless}}
{{/if}}
{{this.badgeForCategory}}
</div>
{{#if this.shouldDisplayDescription}}
<div class="category-desc" aria-hidden="true">{{dir-span
this.descriptionText
htmlSafe="true"
}}</div>
{{/if}}
{{else}}
{{html-safe this.label}}
{{/if}}

View File

@ -1,120 +0,0 @@
import { computed } from "@ember/object";
import { bool, reads } from "@ember/object/computed";
import { htmlSafe } from "@ember/template";
import { isEmpty, isNone } from "@ember/utils";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import { setting } from "discourse/lib/computed";
import Category from "discourse/models/category";
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
export default SelectKitRowComponent.extend({
classNames: ["category-row"],
hideParentCategory: bool("selectKit.options.hideParentCategory"),
allowUncategorized: bool("selectKit.options.allowUncategorized"),
categoryLink: bool("selectKit.options.categoryLink"),
countSubcategories: bool("selectKit.options.countSubcategories"),
allowUncategorizedTopics: setting("allow_uncategorized_topics"),
displayCategoryDescription: computed(
"selectKit.options.displayCategoryDescription",
function () {
const option = this.selectKit.options.displayCategoryDescription;
if (isNone(option)) {
return true;
}
return option;
}
),
title: computed("categoryName", function () {
if (this.category) {
return this.categoryName;
}
}),
categoryName: reads("category.name"),
categoryDescriptionText: reads("category.description_text"),
category: computed("rowValue", "rowName", function () {
if (isEmpty(this.rowValue)) {
const uncategorized = Category.findUncategorized();
if (uncategorized && uncategorized.name === this.rowName) {
return uncategorized;
}
} else {
return Category.findById(parseInt(this.rowValue, 10));
}
}),
badgeForCategory: computed("category", "parentCategory", function () {
return htmlSafe(
categoryBadgeHTML(this.category, {
link: this.categoryLink,
allowUncategorized:
this.allowUncategorizedTopics || this.allowUncategorized,
hideParent: !!this.parentCategory,
topicCount: this.topicCount,
})
);
}),
badgeForParentCategory: computed("parentCategory", function () {
return htmlSafe(
categoryBadgeHTML(this.parentCategory, {
link: this.categoryLink,
allowUncategorized:
this.allowUncategorizedTopics || this.allowUncategorized,
recursive: true,
})
);
}),
parentCategory: computed("parentCategoryId", function () {
return Category.findById(this.parentCategoryId);
}),
hasParentCategory: bool("parentCategoryId"),
parentCategoryId: reads("category.parent_category_id"),
categoryTotalTopicCount: reads("category.totalTopicCount"),
categoryTopicCount: reads("category.topic_count"),
topicCount: computed(
"categoryTotalTopicCount",
"categoryTopicCount",
"countSubcategories",
function () {
return this.countSubcategories
? this.categoryTotalTopicCount
: this.categoryTopicCount;
}
),
shouldDisplayDescription: computed(
"displayCategoryDescription",
"categoryDescriptionText",
function () {
return (
this.displayCategoryDescription &&
this.categoryDescriptionText &&
this.categoryDescriptionText !== "null"
);
}
),
descriptionText: computed("categoryDescriptionText", function () {
if (this.categoryDescriptionText) {
return this._formatDescription(this.categoryDescriptionText);
}
}),
_formatDescription(description) {
const limit = 200;
return `${description.slice(0, limit)}${
description.length > limit ? "&hellip;" : ""
}`;
},
});

View File

@ -2,6 +2,7 @@ import { computed } from "@ember/object";
import { mapBy } from "@ember/object/computed";
import Category from "discourse/models/category";
import { makeArray } from "discourse-common/lib/helpers";
import CategoryRow from "select-kit/components/category-row";
import MultiSelectComponent from "select-kit/components/multi-select";
export default MultiSelectComponent.extend({
@ -46,7 +47,7 @@ export default MultiSelectComponent.extend({
value: mapBy("categories", "id"),
modifyComponentForRow() {
return "category-row";
return CategoryRow;
},
async search(filter) {

View File

@ -768,6 +768,8 @@ export default Component.extend(
} else {
if (this.selectKit.isFilterExpanded) {
this._focusFilter();
this.set("selectKit.highlighted", null);
return;
} else {
highlightedIndex = 0;
}
@ -791,6 +793,8 @@ export default Component.extend(
} else {
if (this.selectKit.isFilterExpanded) {
this._focusFilter();
this.set("selectKit.highlighted", null);
return;
} else {
highlightedIndex = count - 1;
}

View File

@ -16,7 +16,8 @@
"dependencies": {
"ember-auto-import": "^2.7.2",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0"
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.0.0"
},
"devDependencies": {
"@babel/core": "^7.23.7",