FEATURE: Schema theme setting input fields (#25811)

Continue from https://github.com/discourse/discourse/pull/25673.

This commit starts building the inputs pane of schema theme settings. At the moment only string fields are rendered, but more types will be added in future commits.
This commit is contained in:
Osama Sayegh 2024-02-27 01:07:32 +03:00 committed by GitHub
parent 52d357c1d1
commit 3f4537eeb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 431 additions and 121 deletions

View File

@ -1,20 +1,25 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { cached, 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 I18n from "discourse-i18n";
import FieldInput from "./field";
class Node {
text = null;
index = null;
@tracked text;
object;
schema;
index;
active = false;
trees = [];
constructor({ text, index }) {
constructor({ text, index, object, schema }) {
this.text = text;
this.index = index;
this.object = object;
this.schema = schema;
}
}
@ -23,11 +28,12 @@ class Tree {
nodes = [];
}
export default class AdminThemeSettingSchema extends Component {
export default class SchemaThemeSettingEditor extends Component {
@tracked activeIndex = 0;
@tracked backButtonText;
history = [];
@cached
get tree() {
let schema = this.args.schema;
let data = this.args.data;
@ -38,15 +44,19 @@ export default class AdminThemeSettingSchema extends Component {
}
const tree = new Tree();
const idProperty = schema.identifier;
const childObjectsProperties = this.findChildObjectsProperties(
schema.properties
);
data.forEach((obj, index) => {
const node = new Node({ text: obj[idProperty], index });
data.forEach((object, index) => {
const node = new Node({
index,
schema,
object,
text: object[schema.identifier],
});
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;
@ -54,8 +64,10 @@ export default class AdminThemeSettingSchema extends Component {
(childObj, childIndex) => {
subtree.nodes.push(
new Node({
text: childObj[childObjectsProperty.idProperty],
text: childObj[childObjectsProperty.schema.identifier],
index: childIndex,
object: childObj,
schema: childObjectsProperty.schema,
})
);
}
@ -68,14 +80,36 @@ export default class AdminThemeSettingSchema extends Component {
return tree;
}
@cached
get activeNode() {
return this.tree.nodes.find((node, index) => {
return index === this.activeIndex;
});
}
get fields() {
const node = this.activeNode;
const list = [];
for (const [name, spec] of Object.entries(node.schema.properties)) {
if (spec.type === "objects") {
continue;
}
list.push({
name,
type: spec.type,
value: node.object[name],
});
}
return list;
}
findChildObjectsProperties(properties) {
const list = [];
for (const [name, spec] of Object.entries(properties)) {
if (spec.type === "objects") {
const subIdProperty = spec.schema.identifier;
list.push({
name,
idProperty: subIdProperty,
schema: spec.schema,
});
}
}
@ -112,6 +146,14 @@ export default class AdminThemeSettingSchema extends Component {
}
}
@action
inputFieldChanged(field, newVal) {
if (field.name === this.activeNode.schema.identifier) {
this.activeNode.text = newVal;
}
this.activeNode.object[field.name] = newVal;
}
<template>
<div class="schema-editor-navigation">
{{#if this.backButtonText}}
@ -149,6 +191,14 @@ export default class AdminThemeSettingSchema extends Component {
</div>
{{/each}}
</ul>
{{#each this.fields as |field|}}
<FieldInput
@name={{field.name}}
@type={{field.type}}
@value={{field.value}}
@onValueChange={{fn this.inputFieldChanged field}}
/>
{{/each}}
</div>
</template>
}

View File

@ -0,0 +1,30 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
export default class SchemaThemeSettingField extends Component {
#bufferVal;
get component() {
if (this.args.type === "string") {
return Input;
}
}
get value() {
return this.#bufferVal || this.args.value;
}
set value(v) {
this.#bufferVal = v;
this.args.onValueChange(v);
}
<template>
<div class="schema-field" data-name={{@name}}>
<label>{{@name}}</label>
<div class="input">
<this.component @value={{this.value}} />
</div>
</div>
</template>
}

View File

@ -1 +1 @@
<AdminThemeSettingSchema @schema={{this.schema}} @data={{this.data}} />
<SchemaThemeSetting::Editor @schema={{this.schema}} @data={{this.data}} />

View File

@ -0,0 +1,166 @@
export default function schemaAndData(version = 1) {
let schema, data;
if (version === 1) {
schema = {
name: "level1",
identifier: "name",
properties: {
name: {
type: "string",
},
children: {
type: "objects",
schema: {
name: "level2",
identifier: "name",
properties: {
name: {
type: "string",
},
grandchildren: {
type: "objects",
schema: {
name: "level3",
identifier: "name",
properties: {
name: {
type: "string",
},
},
},
},
},
},
},
},
};
data = [
{
name: "item 1",
children: [
{
name: "child 1-1",
grandchildren: [
{
name: "grandchild 1-1-1",
},
{
name: "grandchild 1-1-2",
},
],
},
{
name: "child 1-2",
grandchildren: [
{
name: "grandchild 1-2-1",
},
],
},
],
},
{
name: "item 2",
children: [
{
name: "child 2-1",
grandchildren: [
{
name: "grandchild 2-1-1",
},
{
name: "grandchild 2-1-2",
},
],
},
{
name: "child 2-2",
grandchildren: [
{
name: "grandchild 2-2-1",
},
{
name: "grandchild 2-2-2",
},
{
name: "grandchild 2-2-3",
},
{
name: "grandchild 2-2-4",
},
],
},
{
name: "child 2-3",
grandchildren: [],
},
],
},
];
} else if (version === 2) {
schema = {
name: "section",
identifier: "name",
properties: {
name: {
type: "string",
},
icon: {
type: "string",
},
links: {
type: "objects",
schema: {
name: "link",
identifier: "text",
properties: {
text: {
type: "string",
},
url: {
type: "string",
},
icon: {
type: "string",
},
},
},
},
},
};
data = [
{
name: "nice section",
icon: "arrow",
links: [
{
text: "Privacy",
url: "https://example.com",
icon: "link",
},
],
},
{
name: "cool section",
icon: "bell",
links: [
{
text: "About",
url: "https://example.com/about",
icon: "asterisk",
},
{
text: "Contact",
url: "https://example.com/contact",
icon: "phone",
},
],
},
];
} else {
throw new Error("unknown fixture version");
}
return [schema, data];
}

View File

@ -1,106 +1,10 @@
import { click, render } from "@ember/test-helpers";
import { click, fillIn, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import schemaAndData from "discourse/tests/fixtures/theme-setting-schema-data";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { queryAll } from "discourse/tests/helpers/qunit-helpers";
import I18n from "discourse-i18n";
import AdminThemeSettingSchema from "admin/components/admin-theme-setting-schema";
const schema = {
name: "level1",
identifier: "name",
properties: {
name: {
type: "string",
},
children: {
type: "objects",
schema: {
name: "level2",
identifier: "name",
properties: {
name: {
type: "string",
},
grandchildren: {
type: "objects",
schema: {
name: "level3",
identifier: "name",
properties: {
name: {
type: "string",
},
},
},
},
},
},
},
},
};
const data = [
{
name: "item 1",
children: [
{
name: "child 1-1",
grandchildren: [
{
name: "grandchild 1-1-1",
},
{
name: "grandchild 1-1-2",
},
],
},
{
name: "child 1-2",
grandchildren: [
{
name: "grandchild 1-2-1",
},
],
},
],
},
{
name: "item 2",
children: [
{
name: "child 2-1",
grandchildren: [
{
name: "grandchild 2-1-1",
},
{
name: "grandchild 2-1-2",
},
],
},
{
name: "child 2-2",
grandchildren: [
{
name: "grandchild 2-2-1",
},
{
name: "grandchild 2-2-2",
},
{
name: "grandchild 2-2-3",
},
{
name: "grandchild 2-2-4",
},
],
},
{
name: "child 2-3",
grandchildren: [],
},
],
},
];
import AdminSchemaThemeSettingEditor from "admin/components/schema-theme-setting/editor";
class TreeFromDOM {
constructor() {
@ -130,14 +34,33 @@ class TreeFromDOM {
}
}
class InputFieldsFromDOM {
constructor() {
this.refresh();
}
refresh() {
this.fields = {};
this.count = 0;
[...queryAll(".schema-field")].forEach((field) => {
this.count += 1;
this.fields[field.dataset.name] = {
labelElement: field.querySelector("label"),
inputElement: field.querySelector(".input").children[0],
};
});
}
}
module(
"Integration | Component | admin-theme-settings-schema",
"Integration | Admin | Component | schema-theme-setting/editor",
function (hooks) {
setupRenderingTest(hooks);
test("activates the first node by default", async function (assert) {
const [schema, data] = schemaAndData(1);
await render(<template>
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const tree = new TreeFromDOM();
@ -148,8 +71,9 @@ module(
});
test("renders the 2nd level of nested items for the active item only", async function (assert) {
const [schema, data] = schemaAndData(1);
await render(<template>
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const tree = new TreeFromDOM();
@ -188,8 +112,9 @@ module(
});
test("allows navigating through multiple levels of nesting", async function (assert) {
const [schema, data] = schemaAndData(1);
await render(<template>
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const tree = new TreeFromDOM();
@ -264,8 +189,9 @@ module(
});
test("the back button is only shown when the navigation is at least one level deep", async function (assert) {
const [schema, data] = schemaAndData(1);
await render(<template>
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
assert.dom(".back-button").doesNotExist();
@ -297,8 +223,9 @@ module(
});
test("the back button navigates to the index of the active element at the previous level", async function (assert) {
const [schema, data] = schemaAndData(1);
await render(<template>
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const tree = new TreeFromDOM();
@ -322,8 +249,9 @@ module(
});
test("the back button label includes the name of the item at the previous level", async function (assert) {
const [schema, data] = schemaAndData(1);
await render(<template>
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const tree = new TreeFromDOM();
@ -355,5 +283,141 @@ module(
})
);
});
test("input fields for items at different levels", async function (assert) {
const [schema, data] = schemaAndData(2);
await render(<template>
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const inputFields = new InputFieldsFromDOM();
assert.strictEqual(inputFields.count, 2);
assert.dom(inputFields.fields.name.labelElement).hasText("name");
assert.dom(inputFields.fields.icon.labelElement).hasText("icon");
assert.dom(inputFields.fields.name.inputElement).hasValue("nice section");
assert.dom(inputFields.fields.icon.inputElement).hasValue("arrow");
const tree = new TreeFromDOM();
await click(tree.nodes[1].element);
inputFields.refresh();
tree.refresh();
assert.strictEqual(inputFields.count, 2);
assert.dom(inputFields.fields.name.labelElement).hasText("name");
assert.dom(inputFields.fields.icon.labelElement).hasText("icon");
assert.dom(inputFields.fields.name.inputElement).hasValue("cool section");
assert.dom(inputFields.fields.icon.inputElement).hasValue("bell");
await click(tree.nodes[1].children[0].element);
tree.refresh();
inputFields.refresh();
assert.strictEqual(inputFields.count, 3);
assert.dom(inputFields.fields.text.labelElement).hasText("text");
assert.dom(inputFields.fields.url.labelElement).hasText("url");
assert.dom(inputFields.fields.icon.labelElement).hasText("icon");
assert.dom(inputFields.fields.text.inputElement).hasValue("About");
assert
.dom(inputFields.fields.url.inputElement)
.hasValue("https://example.com/about");
assert.dom(inputFields.fields.icon.inputElement).hasValue("asterisk");
});
test("identifier field instantly updates in the navigation tree when the input field is changed", async function (assert) {
const [schema, data] = schemaAndData(2);
await render(<template>
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const inputFields = new InputFieldsFromDOM();
const tree = new TreeFromDOM();
await fillIn(
inputFields.fields.name.inputElement,
"nice section is really nice"
);
assert.dom(tree.nodes[0].element).hasText("nice section is really nice");
await click(tree.nodes[0].children[0].element);
inputFields.refresh();
tree.refresh();
await fillIn(
inputFields.fields.text.inputElement,
"Security instead of Privacy"
);
assert.dom(tree.nodes[0].element).hasText("Security instead of Privacy");
});
test("edits are remembered when navigating between levels", async function (assert) {
const [schema, data] = schemaAndData(2);
await render(<template>
<AdminSchemaThemeSettingEditor @schema={{schema}} @data={{data}} />
</template>);
const inputFields = new InputFieldsFromDOM();
const tree = new TreeFromDOM();
await fillIn(
inputFields.fields.name.inputElement,
"changed section name"
);
await click(tree.nodes[1].element);
tree.refresh();
inputFields.refresh();
await fillIn(
inputFields.fields.name.inputElement,
"cool section is no longer cool"
);
await click(tree.nodes[1].children[1].element);
tree.refresh();
inputFields.refresh();
assert.dom(".back-button").hasText(
I18n.t("admin.customize.theme.schema.back_button", {
name: "cool section is no longer cool",
})
);
await fillIn(inputFields.fields.text.inputElement, "Talk to us");
await click(".back-button");
tree.refresh();
inputFields.refresh();
assert.dom(tree.nodes[0].element).hasText("changed section name");
assert
.dom(tree.nodes[1].element)
.hasText("cool section is no longer cool");
assert.dom(tree.nodes[1].children[0].element).hasText("About");
assert.dom(tree.nodes[1].children[1].element).hasText("Talk to us");
assert
.dom(inputFields.fields.name.inputElement)
.hasValue("cool section is no longer cool");
await click(tree.nodes[1].children[1].element);
tree.refresh();
inputFields.refresh();
assert.dom(inputFields.fields.text.inputElement).hasValue("Talk to us");
});
}
);