DEV: Rewrite `SchemaThemeSetting::Editor` to avoid rerendering problems (#26416)

Why this change?

Prior to this change, the `SchemaThemeSetting::Editor#tree` was creating a
new `Tree` instance which holds instances of `Node`. Both classes
consisted of tracked properties. The problem with this approach is that
when any tracked properties is updated, Ember will revaluate
`SchemaThemeSetting::Editor#tree` and because that method always return
a new instance of `Tree`, it causes the whole navigation tree to
rerender just because on tracked property changed.

This rerendering of the whole navigation tree every time made it hard to
implement simple features like hiding a section in
9baa820d53. Instead of being able to just
declare a tracked property to hide/show a section, we end up with a more
complicated solution.

This commit rewrites `SchemaThemeSetting::Editor` to depend on Ember
components to form the tree structure instead. As needed, each component
in the tree structure can declare its own tracked property as necessary.
This commit is contained in:
Alan Guo Xiang Tan 2024-03-28 21:13:02 +08:00 committed by GitHub
parent 1ab2fe0a81
commit 70f7d369fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 406 additions and 379 deletions

View File

@ -1,160 +1,161 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import dIcon from "discourse-common/helpers/d-icon";
import { cloneJSON } from "discourse-common/lib/object";
import I18n from "discourse-i18n";
import FieldInput from "./field";
import Tree from "admin/components/schema-theme-setting/editor/tree";
import FieldInput from "admin/components/schema-theme-setting/field";
class Node {
@tracked text;
object;
schema;
index;
active = false;
parentTree;
trees = [];
constructor({ text, index, object, schema, parentTree }) {
this.text = text;
this.index = index;
this.object = object;
this.schema = schema;
this.parentTree = parentTree;
}
}
class Tree {
@tracked nodes = [];
data = [];
propertyName;
schema;
}
export default class SchemaThemeSettingEditor extends Component {
export default class SchemaThemeSettingNewEditor extends Component {
@service router;
@tracked history = [];
@tracked activeIndex = 0;
@tracked backButtonText;
@tracked activeDataPaths = [];
@tracked activeSchemaPaths = [];
@tracked saveButtonDisabled = false;
@tracked visibilityStates = [];
inputFieldObserver = new Map();
data = cloneJSON(this.args.setting.value);
history = [];
schema = this.args.setting.objects_schema;
@cached
get tree() {
let schema = this.schema;
let data = this.data;
let tree = new Tree();
tree.data = data;
tree.schema = schema;
for (const point of this.history) {
data = data[point.parentNode.index][point.propertyName];
schema = schema.properties[point.propertyName].schema;
tree.propertyName = point.propertyName;
tree.schema = point.node.schema;
tree.data = data;
}
data.forEach((object, index) => {
const node = new Node({
index,
schema,
object,
text:
object[schema.identifier] ||
this.defaultSchemaIdentifier(schema.name, index),
parentTree: tree,
});
if (index === this.activeIndex) {
node.active = true;
const childObjectsProperties = this.findChildObjectsProperties(
schema.properties
);
for (const childObjectsProperty of childObjectsProperties) {
const subtree = new Tree();
subtree.propertyName = childObjectsProperty.name;
subtree.schema = childObjectsProperty.schema;
subtree.data = data[index][childObjectsProperty.name] ||= [];
data[index][childObjectsProperty.name]?.forEach(
(childObj, childIndex) => {
subtree.nodes.push(
new Node({
text:
childObj[childObjectsProperty.schema.identifier] ||
`${childObjectsProperty.schema.name} ${childIndex + 1}`,
index: childIndex,
object: childObj,
schema: childObjectsProperty.schema,
parentTree: subtree,
})
);
}
);
node.trees.push(subtree);
}
}
tree.nodes.push(node);
@action
onChildClick(index, propertyName, parentNodeIndex) {
this.history.pushObject({
dataPaths: [...this.activeDataPaths],
schemaPaths: [...this.activeSchemaPaths],
index: this.activeIndex,
});
return tree;
this.activeIndex = index;
this.activeDataPaths.pushObjects([parentNodeIndex, propertyName]);
this.activeSchemaPaths.pushObject(propertyName);
this.inputFieldObserver.clear();
}
@cached
get activeNode() {
return this.tree.nodes.find((node, index) => {
return index === this.activeIndex;
@action
updateIndex(index) {
this.activeIndex = index;
}
generateSchemaTitle(object, schema, index) {
return object[schema.identifier] || `${schema.name} ${index + 1}`;
}
get backButtonText() {
if (this.history.length === 0) {
return;
}
const lastHistory = this.history[this.history.length - 1];
return I18n.t("admin.customize.theme.schema.back_button", {
name: this.generateSchemaTitle(
this.#resolveDataFromPaths(lastHistory.dataPaths)[lastHistory.index],
this.#resolveSchemaFromPaths(lastHistory.schemaPaths),
lastHistory.index
),
});
}
get activeData() {
return this.#resolveDataFromPaths(this.activeDataPaths);
}
#resolveDataFromPaths(paths) {
if (paths.length === 0) {
return this.data;
}
let data = this.data;
paths.forEach((path) => {
data = data[path];
});
return data;
}
get activeSchema() {
return this.#resolveSchemaFromPaths(this.activeSchemaPaths);
}
#resolveSchemaFromPaths(paths) {
if (paths.length === 0) {
return this.schema;
}
let schema = this.schema;
paths.forEach((path) => {
schema = schema.properties[path].schema;
});
return schema;
}
@action
registerInputFieldObserver(index, callback) {
this.inputFieldObserver[index] = callback;
}
@action
unregisterInputFieldObserver(index) {
delete this.inputFieldObserver[index];
}
descriptions(fieldName, key) {
// The `property_descriptions` metadata is an object with keys in the following format as an example:
//
// {
// some_property.description: <some description>,
// some_property.label: <some label>,
// some_objects_property.some_other_property.description: <some description>,
// some_objects_property.some_other_property.label: <some label>,
// }
const descriptions = this.args.setting.metadata?.property_descriptions;
if (!descriptions) {
return;
}
if (this.activeSchemaPaths.length > 0) {
key = `${this.activeSchemaPaths.join(".")}.${fieldName}.${key}`;
} else {
key = `${fieldName}.${key}`;
}
return descriptions[key];
}
fieldLabel(fieldName) {
return this.descriptions(fieldName, "label") || fieldName;
}
fieldDescription(fieldName) {
return this.descriptions(fieldName, "description");
}
get fields() {
const node = this.activeNode;
const list = [];
if (!node) {
return list;
}
if (this.activeData.length !== 0) {
for (const [name, spec] of Object.entries(this.activeSchema.properties)) {
if (spec.type === "objects") {
continue;
}
for (const [name, spec] of Object.entries(node.schema.properties)) {
if (spec.type === "objects") {
continue;
}
list.push({
name,
spec,
value: node.object[name],
description: this.fieldDescription(name),
label: this.fieldLabel(name),
});
}
return list;
}
findChildObjectsProperties(properties) {
const list = [];
for (const [name, spec] of Object.entries(properties)) {
if (spec.type === "objects") {
list.push({
name,
schema: spec.schema,
spec,
value: this.activeData[this.activeIndex][name],
description: this.fieldDescription(name),
label: this.fieldLabel(name),
});
}
}
@ -162,6 +163,52 @@ export default class SchemaThemeSettingEditor extends Component {
return list;
}
@action
clickBack() {
const {
dataPaths: lastDataPaths,
schemaPaths: lastSchemaPaths,
index: lastIndex,
} = this.history.popObject();
this.activeDataPaths = lastDataPaths;
this.activeSchemaPaths = lastSchemaPaths;
this.activeIndex = lastIndex;
this.inputFieldObserver.clear();
}
@action
addChildItem(propertyName, parentNodeIndex) {
this.activeData[parentNodeIndex][propertyName].pushObject({});
}
@action
addItem() {
this.activeData.pushObject({});
}
@action
removeItem() {
this.activeData.removeAt(this.activeIndex);
if (this.activeData.length > 0) {
this.activeIndex = Math.max(this.activeIndex - 1, 0);
} else if (this.history.length > 0) {
this.clickBack();
} else {
this.activeIndex = 0;
}
}
@action
inputFieldChanged(field, newVal) {
this.activeData[this.activeIndex][field.name] = newVal;
if (field.name === this.activeSchema.identifier) {
this.inputFieldObserver[this.activeIndex]();
}
}
@action
saveChanges() {
this.saveButtonDisabled = true;
@ -180,260 +227,23 @@ export default class SchemaThemeSettingEditor extends Component {
.finally(() => (this.saveButtonDisabled = false));
}
@action
onClick(node) {
this.activeIndex = node.index;
}
@action
onChildClick(node, tree, parentNode) {
this.history.push({
propertyName: tree.propertyName,
parentNode,
node,
});
this.backButtonText = I18n.t("admin.customize.theme.schema.back_button", {
name: parentNode.text,
});
this.activeIndex = node.index;
}
@action
backButtonClick() {
const historyPoint = this.history.pop();
this.activeIndex = historyPoint.parentNode.index;
if (this.history.length > 0) {
this.backButtonText = I18n.t("admin.customize.theme.schema.back_button", {
name: this.history[this.history.length - 1].parentNode.text,
});
} else {
this.backButtonText = null;
}
}
@action
inputFieldChanged(field, newVal) {
if (field.name === this.activeNode.schema.identifier) {
this.activeNode.text = newVal;
}
this.activeNode.object[field.name] = newVal;
}
@action
addItem(tree) {
const schema = tree.schema;
const node = this.createNodeFromSchema(schema, tree);
tree.data.push(node.object);
tree.nodes = [...tree.nodes, node];
}
@action
removeItem() {
const data = this.activeNode.parentTree.data;
data.splice(this.activeIndex, 1);
this.tree.nodes = this.tree.nodes.filter((n, i) => i !== this.activeIndex);
if (data.length > 0) {
this.activeIndex = Math.max(this.activeIndex - 1, 0);
} else if (this.history.length > 0) {
this.backButtonClick();
}
}
@action
toggleListVisibility(listIdentifier) {
if (this.visibilityStates.includes(listIdentifier)) {
this.visibilityStates = this.visibilityStates.filter(
(id) => id !== listIdentifier
);
} else {
this.visibilityStates = [...this.visibilityStates, listIdentifier];
}
}
get isListVisible() {
return (listIdentifier) => {
return this.visibilityStates.includes(listIdentifier);
};
}
descriptions(fieldName, key) {
// The `property_descriptions` metadata is an object with keys in the following format as an example:
//
// {
// some_property.description: <some description>,
// some_property.label: <some label>,
// some_objects_property.some_other_property.description: <some description>,
// some_objects_property.some_other_property.label: <some label>,
// }
const descriptions = this.args.setting.metadata?.property_descriptions;
if (!descriptions) {
return;
}
if (this.activeNode.parentTree.propertyName) {
key = `${this.activeNode.parentTree.propertyName}.${fieldName}.${key}`;
} else {
key = `${fieldName}.${key}`;
}
return descriptions[key];
}
fieldLabel(fieldName) {
return this.descriptions(fieldName, "label") || fieldName;
}
fieldDescription(fieldName) {
return this.descriptions(fieldName, "description");
}
defaultSchemaIdentifier(schemaName, index) {
return `${schemaName} ${index + 1}`;
}
createNodeFromSchema(schema, tree) {
const object = {};
const index = tree.nodes.length;
const defaultName = this.defaultSchemaIdentifier(schema.name, index);
if (schema.identifier) {
object[schema.identifier] = defaultName;
}
for (const [name, spec] of Object.entries(schema.properties)) {
if (spec.type === "objects") {
object[name] = [];
}
}
return new Node({
schema,
object,
index,
text: defaultName,
parentTree: tree,
});
}
uniqueNodeId(nestedTreePropertyName, nodeIndex) {
return `${nestedTreePropertyName}-${nodeIndex}`;
}
<template>
{{! template-lint-disable no-nested-interactive }}
<div class="schema-theme-setting-editor">
<div class="schema-theme-setting-editor__navigation">
<ul class="schema-theme-setting-editor__tree">
{{#if this.backButtonText}}
<li
role="link"
class="schema-theme-setting-editor__tree-node --back-btn"
{{on "click" this.backButtonClick}}
>
<div class="schema-theme-setting-editor__tree-node-text">
{{dIcon "arrow-left"}}
{{this.backButtonText}}
</div>
</li>
{{/if}}
{{#each this.tree.nodes as |node|}}
<li
role="link"
class="schema-theme-setting-editor__tree-node --parent
{{if node.active ' --active'}}"
{{on "click" (fn this.onClick node)}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<span>{{node.text}}</span>
{{#if node.parentTree.propertyName}}
{{dIcon "chevron-right"}}
{{else}}
{{dIcon (if node.active "chevron-down" "chevron-right")}}
{{/if}}
</div>
{{#each node.trees as |nestedTree|}}
<div
class="schema-theme-setting-editor__tree-node --heading"
role="button"
{{on
"click"
(fn
this.toggleListVisibility
(this.uniqueNodeId nestedTree.propertyName node.index)
)
}}
>
{{nestedTree.propertyName}}
{{dIcon
(if
(this.isListVisible
(this.uniqueNodeId nestedTree.propertyName node.index)
)
"chevron-right"
"chevron-down"
)
}}
</div>
<ul
class={{if
(this.isListVisible
(this.uniqueNodeId nestedTree.propertyName node.index)
)
"--is-hidden"
"--is-visible"
}}
>
{{#each nestedTree.nodes as |childNode|}}
<li
role="link"
class="schema-theme-setting-editor__tree-node --child"
{{on
"click"
(fn this.onChildClick childNode nestedTree node)
}}
data-test-parent-index={{node.index}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<span>{{childNode.text}}</span>
{{dIcon "chevron-right"}}
</div>
</li>
{{/each}}
<li
class="schema-theme-setting-editor__tree-node --child --add-button"
>
<DButton
@action={{fn this.addItem nestedTree}}
@translatedLabel={{nestedTree.schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --child"
data-test-parent-index={{node.index}}
/>
</li>
</ul>
{{/each}}
</li>
{{/each}}
<li
class="schema-theme-setting-editor__tree-node --parent --add-button"
>
<DButton
@action={{fn this.addItem this.tree}}
@translatedLabel={{this.tree.schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --root"
/>
</li>
</ul>
<Tree
@data={{this.activeData}}
@schema={{this.activeSchema}}
@onChildClick={{this.onChildClick}}
@clickBack={{this.clickBack}}
@backButtonText={{this.backButtonText}}
@activeIndex={{this.activeIndex}}
@updateIndex={{this.updateIndex}}
@addItem={{this.addItem}}
@addChildItem={{this.addChildItem}}
@generateSchemaTitle={{this.generateSchemaTitle}}
@registerInputFieldObserver={{this.registerInputFieldObserver}}
@unregisterInputFieldObserver={{this.unregisterInputFieldObserver}}
/>
<div class="schema-theme-setting-editor__footer">
<DButton
@ -453,10 +263,11 @@ export default class SchemaThemeSettingEditor extends Component {
@spec={{field.spec}}
@onValueChange={{fn this.inputFieldChanged field}}
@description={{field.description}}
@setting={{@setting}}
@label={{field.label}}
@setting={{@setting}}
/>
{{/each}}
{{#if (gt this.fields.length 0)}}
<DButton
@action={{this.removeItem}}

View File

@ -0,0 +1,16 @@
import { on } from "@ember/modifier";
import dIcon from "discourse-common/helpers/d-icon";
<template>
<li
role="link"
class="schema-theme-setting-editor__tree-node --child"
...attributes
{{on "click" @onChildClick}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<span>{{@generateSchemaTitle @object @schema @index}}</span>
{{dIcon "chevron-right"}}
</div>
</li>
</template>

View File

@ -0,0 +1,63 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import dIcon from "discourse-common/helpers/d-icon";
import ChildTreeNode from "admin/components/schema-theme-setting/editor/child-tree-node";
export default class SchemaThemeSettingNewEditorChildTree extends Component {
@tracked expanded = true;
@action
toggleVisibility() {
this.expanded = !this.expanded;
}
@action
onChildClick(index) {
return this.args.onChildClick(
index,
this.args.name,
this.args.parentNodeIndex,
this.args.parentNodeText
);
}
<template>
<div
class="schema-theme-setting-editor__tree-node --heading"
role="button"
{{on "click" this.toggleVisibility}}
>
{{@name}}
{{dIcon (if this.expanded "chevron-down" "chevron-right")}}
</div>
{{#if this.expanded}}
<ul>
{{#each @objects as |object index|}}
<ChildTreeNode
@index={{index}}
@object={{object}}
@onChildClick={{fn this.onChildClick index}}
@schema={{@schema}}
@generateSchemaTitle={{@generateSchemaTitle}}
data-test-parent-index={{@parentNodeIndex}}
/>
{{/each}}
<li class="schema-theme-setting-editor__tree-node --child --add-button">
<DButton
@action={{fn @addChildItem @name @parentNodeIndex}}
@translatedLabel={{@schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --child"
data-test-parent-index={{@parentNodeIndex}}
/>
</li>
</ul>
{{/if}}
</template>
}

View File

@ -0,0 +1,90 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, get } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { gt } from "truth-helpers";
import dIcon from "discourse-common/helpers/d-icon";
import ChildTree from "admin/components/schema-theme-setting/editor/child-tree";
export default class SchemaThemeSettingNewEditorTreeNode extends Component {
@tracked text;
childObjectsProperties = this.findChildObjectsProperties(
this.args.schema.properties
);
constructor() {
super(...arguments);
this.#setText();
}
@action
registerInputFieldObserver() {
this.args.registerInputFieldObserver(
this.args.index,
this.#setText.bind(this)
);
}
#setText() {
this.text = this.args.generateSchemaTitle(
this.args.object,
this.args.schema,
this.args.index
);
}
findChildObjectsProperties(properties) {
const list = [];
for (const [name, spec] of Object.entries(properties)) {
if (spec.type === "objects") {
this.args.object[name] ||= [];
list.push({
name,
schema: spec.schema,
});
}
}
return list;
}
<template>
<li
role="link"
class="schema-theme-setting-editor__tree-node --parent
{{if @active ' --active'}}"
{{on "click" (fn @onClick @index)}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<span {{didInsert this.registerInputFieldObserver}}>{{this.text}}</span>
{{#if (gt this.childObjectsProperties.length 0)}}
{{dIcon (if @active "chevron-down" "chevron-right")}}
{{else}}
{{dIcon "chevron-right"}}
{{/if}}
</div>
{{#if @active}}
{{#each this.childObjectsProperties as |childObjectsProperty|}}
<ChildTree
@name={{childObjectsProperty.name}}
@schema={{childObjectsProperty.schema}}
@objects={{get @object childObjectsProperty.name}}
@parentNodeText={{this.text}}
@parentNodeIndex={{@index}}
@onChildClick={{@onChildClick}}
@addChildItem={{@addChildItem}}
@generateSchemaTitle={{@generateSchemaTitle}}
/>
{{/each}}
{{/if}}
</li>
</template>
}

View File

@ -0,0 +1,47 @@
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import dIcon from "discourse-common/helpers/d-icon";
import TreeNode from "admin/components/schema-theme-setting/editor/tree-node";
<template>
<ul class="schema-theme-setting-editor__tree">
{{#if @backButtonText}}
<li
role="link"
class="schema-theme-setting-editor__tree-node --back-btn"
{{on "click" @clickBack}}
>
<div class="schema-theme-setting-editor__tree-node-text">
{{dIcon "arrow-left"}}
{{@backButtonText}}
</div>
</li>
{{/if}}
{{#each @data as |object index|}}
<TreeNode
@index={{index}}
@object={{object}}
@active={{eq @activeIndex index}}
@onClick={{fn @updateIndex index}}
@onChildClick={{@onChildClick}}
@schema={{@schema}}
@addChildItem={{@addChildItem}}
@generateSchemaTitle={{@generateSchemaTitle}}
@registerInputFieldObserver={{@registerInputFieldObserver}}
@unregisterInputFieldObserver={{@unregisterInputFieldObserver}}
/>
{{/each}}
<li class="schema-theme-setting-editor__tree-node --parent --add-button">
<DButton
@action={{@addItem}}
@translatedLabel={{@schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --root"
/>
</li>
</ul>
</template>