diff --git a/app/assets/javascripts/admin/addon/components/admin-theme-setting-schema.gjs b/app/assets/javascripts/admin/addon/components/admin-theme-setting-schema.gjs new file mode 100644 index 00000000000..5c60e44d078 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-theme-setting-schema.gjs @@ -0,0 +1,125 @@ +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 { tagName } from "@ember-decorators/component"; + +class Node { + text = null; + index = null; + active = false; + trees = []; + + constructor({ text, index }) { + this.text = text; + this.index = index; + } +} + +class Tree { + propertyName = null; + nodes = []; +} + +@tagName("") +export default class AdminThemeSettingSchema extends Component { + @tracked activeIndex = 0; + history = []; + + get tree() { + let schema = this.args.schema; + let data = this.args.data; + + for (const point of this.history) { + data = data[point]; + if (typeof point === "string") { + schema = schema.properties[point].schema; + } + } + + 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 }); + if (index === this.activeIndex) { + node.active = true; + for (const childObjectsProperty of childObjectsProperties) { + const subtree = new Tree(); + subtree.propertyName = childObjectsProperty.name; + data[index][childObjectsProperty.name].forEach( + (childObj, childIndex) => { + subtree.nodes.push( + new Node({ + text: childObj[childObjectsProperty.idProperty], + index: childIndex, + }) + ); + } + ); + node.trees.push(subtree); + } + } + tree.nodes.push(node); + }); + return tree; + } + + 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, + }); + } + } + return list; + } + + @action + onClick(node) { + this.activeIndex = node.index; + } + + @action + onChildClick(node, tree) { + this.history.push(this.activeIndex, tree.propertyName); + this.activeIndex = node.index; + } + + +} diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-schema.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-schema.js new file mode 100644 index 00000000000..1c6098693a5 --- /dev/null +++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-schema.js @@ -0,0 +1,82 @@ +import Controller from "@ember/controller"; + +export default class AdminCustomizeThemesSchemaController extends Controller { + data = [ + { + name: "item 1", + children: [ + { + name: "child 1-1", + grandchildren: [ + { + name: "grandchild 1-1-1", + }, + ], + }, + { + 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: "child 2-2", + grandchildren: [ + { + name: "grandchild 2-2-1", + }, + ], + }, + ], + }, + ]; + + schema = { + name: "item", + identifier: "name", + properties: { + name: { + type: "string", + }, + children: { + type: "objects", + schema: { + name: "child", + identifier: "name", + properties: { + name: { + type: "string", + }, + grandchildren: { + type: "objects", + schema: { + name: "grandchild", + identifier: "name", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }; +} diff --git a/app/assets/javascripts/admin/addon/routes/admin-customize-themes-schema.js b/app/assets/javascripts/admin/addon/routes/admin-customize-themes-schema.js new file mode 100644 index 00000000000..416dc2a0df2 --- /dev/null +++ b/app/assets/javascripts/admin/addon/routes/admin-customize-themes-schema.js @@ -0,0 +1,8 @@ +import Route from "@ember/routing/route"; + +export default class AdminCustomizeThemesSchemaRoute extends Route { + setupController() { + super.setupController(...arguments); + this.controllerFor("adminCustomizeThemes").set("editingTheme", true); + } +} diff --git a/app/assets/javascripts/admin/addon/routes/admin-route-map.js b/app/assets/javascripts/admin/addon/routes/admin-route-map.js index 89dc7cb2c1f..5a37980aa57 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-route-map.js +++ b/app/assets/javascripts/admin/addon/routes/admin-route-map.js @@ -59,6 +59,7 @@ export default function () { function () { this.route("show", { path: "/:theme_id" }); this.route("edit", { path: "/:theme_id/:target/:field_name/edit" }); + this.route("schema", { path: "/:theme_id/schema/:setting_name" }); } ); diff --git a/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs b/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs new file mode 100644 index 00000000000..5c128895600 --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/customize-themes-schema.hbs @@ -0,0 +1 @@ + \ No newline at end of file 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-theme-setting-schema-test.gjs new file mode 100644 index 00000000000..f31aa9d71cd --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/admin-theme-setting-schema-test.gjs @@ -0,0 +1,259 @@ +import { click, render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { queryAll } from "discourse/tests/helpers/qunit-helpers"; +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: [], + }, + ], + }, +]; + +function queryRenderedTree() { + return [...queryAll(".tree .item-container")].map((container) => { + const li = container.querySelector(".parent.node"); + const active = li.classList.contains("active"); + const children = [...container.querySelectorAll(".node.child")].map( + (child) => { + return { + text: child.textContent.trim(), + element: child, + }; + } + ); + + return { + text: li.textContent.trim(), + active, + children, + element: li, + }; + }); +} + +module( + "Integration | Component | admin-theme-settings-schema", + function (hooks) { + setupRenderingTest(hooks); + + test("activates the first node by default", async function (assert) { + await render(); + + const tree = queryRenderedTree(); + + assert.equal(tree.length, 2); + assert.true(tree[0].active, "the first node is active"); + assert.false(tree[1].active, "other nodes are not active"); + }); + + test("renders the 2nd level of nested items for the active item only", async function (assert) { + await render(); + + let tree = queryRenderedTree(); + + assert.true(tree[0].active); + assert.equal( + tree[0].children.length, + 2, + "the children of the active node are shown" + ); + + assert.false(tree[1].active); + assert.equal( + tree[1].children.length, + 0, + "thie children of an active node aren't shown" + ); + + await click(tree[1].element); + + tree = queryRenderedTree(); + + assert.false(tree[0].active); + assert.equal( + tree[0].children.length, + 0, + "thie children of an active node aren't shown" + ); + + assert.true(tree[1].active); + assert.equal( + tree[1].children.length, + 3, + "the children of the active node are shown" + ); + }); + + test("allows navigating through multiple levels of nesting", async function (assert) { + await render(); + + let tree = queryRenderedTree(); + + assert.equal(tree.length, 2); + assert.equal(tree[0].text, "item 1"); + assert.equal(tree[0].children.length, 2); + assert.equal(tree[0].children[0].text, "child 1-1"); + assert.equal(tree[0].children[1].text, "child 1-2"); + + assert.equal(tree[1].text, "item 2"); + assert.equal(tree[1].children.length, 0); + + await click(tree[1].element); + + tree = queryRenderedTree(); + + assert.equal(tree.length, 2); + assert.equal(tree[0].text, "item 1"); + assert.false(tree[0].active); + assert.equal(tree[0].children.length, 0); + + assert.equal(tree[1].text, "item 2"); + assert.true(tree[1].active); + assert.equal(tree[1].children.length, 3); + assert.equal(tree[1].children[0].text, "child 2-1"); + assert.equal(tree[1].children[1].text, "child 2-2"); + assert.equal(tree[1].children[2].text, "child 2-3"); + + await click(tree[1].children[1].element); + + tree = queryRenderedTree(); + assert.equal(tree.length, 3); + + assert.equal(tree[0].text, "child 2-1"); + assert.false(tree[0].active); + assert.equal(tree[0].children.length, 0); + + assert.equal(tree[1].text, "child 2-2"); + assert.true(tree[1].active); + assert.equal(tree[1].children.length, 4); + assert.equal(tree[1].children[0].text, "grandchild 2-2-1"); + assert.equal(tree[1].children[1].text, "grandchild 2-2-2"); + assert.equal(tree[1].children[2].text, "grandchild 2-2-3"); + assert.equal(tree[1].children[3].text, "grandchild 2-2-4"); + + assert.equal(tree[2].text, "child 2-3"); + assert.false(tree[2].active); + assert.equal(tree[2].children.length, 0); + + await click(tree[1].children[1].element); + + tree = queryRenderedTree(); + + assert.equal(tree.length, 4); + + assert.equal(tree[0].text, "grandchild 2-2-1"); + assert.false(tree[0].active); + assert.equal(tree[0].children.length, 0); + + assert.equal(tree[1].text, "grandchild 2-2-2"); + assert.true(tree[1].active); + assert.equal(tree[1].children.length, 0); + + assert.equal(tree[2].text, "grandchild 2-2-3"); + assert.false(tree[2].active); + assert.equal(tree[2].children.length, 0); + + assert.equal(tree[3].text, "grandchild 2-2-4"); + assert.false(tree[3].active); + assert.equal(tree[3].children.length, 0); + }); + } +); diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 88e44ce0776..dc86545c9b2 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -329,6 +329,10 @@ class Admin::ThemesController < Admin::AdminController render json: updated_setting, status: :ok end + def schema + raise Discourse::InvalidAccess if !SiteSetting.experimental_objects_type_for_theme_settings + end + private def ban_in_allowlist_mode! diff --git a/config/routes.rb b/config/routes.rb index 2fc68179f6b..82d11d5382b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -253,6 +253,7 @@ Discourse::Application.routes.draw do get "themes/:id/:target/:field_name/edit" => "themes#index" get "themes/:id" => "themes#index" get "themes/:id/export" => "themes#export" + get "themes/:id/schema/:setting_name" => "themes#schema" # They have periods in their URLs often: get "site_texts" => "site_texts#index"