Alan Guo Xiang Tan 70f7d369fb
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
9baa820d53757aec36e4cb86efab8d0bb3f264c0. 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.
2024-03-28 21:13:02 +08:00

282 lines
7.3 KiB
Plaintext

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
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 { cloneJSON } from "discourse-common/lib/object";
import I18n from "discourse-i18n";
import Tree from "admin/components/schema-theme-setting/editor/tree";
import FieldInput from "admin/components/schema-theme-setting/field";
export default class SchemaThemeSettingNewEditor extends Component {
@service router;
@tracked history = [];
@tracked activeIndex = 0;
@tracked activeDataPaths = [];
@tracked activeSchemaPaths = [];
@tracked saveButtonDisabled = false;
inputFieldObserver = new Map();
data = cloneJSON(this.args.setting.value);
schema = this.args.setting.objects_schema;
@action
onChildClick(index, propertyName, parentNodeIndex) {
this.history.pushObject({
dataPaths: [...this.activeDataPaths],
schemaPaths: [...this.activeSchemaPaths],
index: this.activeIndex,
});
this.activeIndex = index;
this.activeDataPaths.pushObjects([parentNodeIndex, propertyName]);
this.activeSchemaPaths.pushObject(propertyName);
this.inputFieldObserver.clear();
}
@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 list = [];
if (this.activeData.length !== 0) {
for (const [name, spec] of Object.entries(this.activeSchema.properties)) {
if (spec.type === "objects") {
continue;
}
list.push({
name,
spec,
value: this.activeData[this.activeIndex][name],
description: this.fieldDescription(name),
label: this.fieldLabel(name),
});
}
}
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;
this.args.setting
.updateSetting(this.args.themeId, this.data)
.then((result) => {
this.args.setting.set("value", result[this.args.setting.setting]);
this.router.transitionTo(
"adminCustomizeThemes.show",
this.args.themeId
);
})
.catch(popupAjaxError)
.finally(() => (this.saveButtonDisabled = false));
}
<template>
<div class="schema-theme-setting-editor">
<div class="schema-theme-setting-editor__navigation">
<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
@disabled={{this.saveButtonDisabled}}
@action={{this.saveChanges}}
@label="save"
class="btn-primary"
/>
</div>
</div>
<div class="schema-theme-setting-editor__fields">
{{#each this.fields as |field|}}
<FieldInput
@name={{field.name}}
@value={{field.value}}
@spec={{field.spec}}
@onValueChange={{fn this.inputFieldChanged field}}
@description={{field.description}}
@label={{field.label}}
@setting={{@setting}}
/>
{{/each}}
{{#if (gt this.fields.length 0)}}
<DButton
@action={{this.removeItem}}
@icon="trash-alt"
class="btn-danger schema-theme-setting-editor__remove-btn"
/>
{{/if}}
</div>
</div>
</template>
}