From 3f4537eeb5ae5b1793ca72d0b4e93cd355767893 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Tue, 27 Feb 2024 01:07:32 +0300 Subject: [PATCH] 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. --- .../editor.gjs} | 78 ++++- .../components/schema-theme-setting/field.gjs | 30 ++ .../templates/customize-themes-schema.hbs | 2 +- .../fixtures/theme-setting-schema-data.js | 166 +++++++++++ .../editor-test.gjs} | 276 +++++++++++------- 5 files changed, 431 insertions(+), 121 deletions(-) rename app/assets/javascripts/admin/addon/components/{admin-theme-setting-schema.gjs => schema-theme-setting/editor.gjs} (67%) create mode 100644 app/assets/javascripts/admin/addon/components/schema-theme-setting/field.gjs create mode 100644 app/assets/javascripts/discourse/tests/fixtures/theme-setting-schema-data.js rename app/assets/javascripts/discourse/tests/integration/components/{admin-theme-setting-schema-test.gjs => admin-schema-theme-setting/editor-test.gjs} (57%) diff --git a/app/assets/javascripts/admin/addon/components/admin-theme-setting-schema.gjs b/app/assets/javascripts/admin/addon/components/schema-theme-setting/editor.gjs similarity index 67% rename from app/assets/javascripts/admin/addon/components/admin-theme-setting-schema.gjs rename to app/assets/javascripts/admin/addon/components/schema-theme-setting/editor.gjs index 172b592050c..38d98d1e08f 100644 --- a/app/assets/javascripts/admin/addon/components/admin-theme-setting-schema.gjs +++ b/app/assets/javascripts/admin/addon/components/schema-theme-setting/editor.gjs @@ -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; + } + } diff --git a/app/assets/javascripts/admin/addon/components/schema-theme-setting/field.gjs b/app/assets/javascripts/admin/addon/components/schema-theme-setting/field.gjs new file mode 100644 index 00000000000..e033f99cadf --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/schema-theme-setting/field.gjs @@ -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); + } + + +} diff --git a/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs b/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs index 5c128895600..526551802a4 100644 --- a/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs +++ b/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/tests/fixtures/theme-setting-schema-data.js b/app/assets/javascripts/discourse/tests/fixtures/theme-setting-schema-data.js new file mode 100644 index 00000000000..7add942a8ef --- /dev/null +++ b/app/assets/javascripts/discourse/tests/fixtures/theme-setting-schema-data.js @@ -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]; +} diff --git a/app/assets/javascripts/discourse/tests/integration/components/admin-theme-setting-schema-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/admin-schema-theme-setting/editor-test.gjs similarity index 57% rename from app/assets/javascripts/discourse/tests/integration/components/admin-theme-setting-schema-test.gjs rename to app/assets/javascripts/discourse/tests/integration/components/admin-schema-theme-setting/editor-test.gjs index 200052f3583..fbe35dacbd4 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/admin-theme-setting-schema-test.gjs +++ b/app/assets/javascripts/discourse/tests/integration/components/admin-schema-theme-setting/editor-test.gjs @@ -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(); 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(); 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(); 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(); 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(); 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(); 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(); + + 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(); + + 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(); + + 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"); + }); } );