mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 13:41:31 +08:00
FEATURE: Groundwork for schema theme settings UI (#25673)
This commit is the first of a series of commits that will allow themes to define complex settings types by declaring a schema of the setting structure that Discourse core will use to build a UI for the setting automatically. We implement the navigation logic and support for multiple levels of nesting in this commit and we'll continue building this new system gradually in future commits. Internal topic: t/116870.
This commit is contained in:
parent
5935148bd8
commit
9329a5395a
|
@ -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;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="schema-editor-navigation">
|
||||
<ul class="tree">
|
||||
{{#each this.tree.nodes as |node|}}
|
||||
<div class="item-container">
|
||||
<li
|
||||
role="link"
|
||||
class="parent node{{if node.active ' active'}}"
|
||||
{{on "click" (fn this.onClick node)}}
|
||||
>
|
||||
{{node.text}}
|
||||
</li>
|
||||
{{#each node.trees as |nestedTree|}}
|
||||
<ul>
|
||||
{{#each nestedTree.nodes as |childNode|}}
|
||||
<li
|
||||
role="link"
|
||||
class="child node"
|
||||
{{on "click" (fn this.onChildClick childNode nestedTree)}}
|
||||
>{{childNode.text}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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" });
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<AdminThemeSettingSchema @schema={{this.schema}} @data={{this.data}} />
|
|
@ -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(<template>
|
||||
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
|
||||
</template>);
|
||||
|
||||
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(<template>
|
||||
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
|
||||
</template>);
|
||||
|
||||
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(<template>
|
||||
<AdminThemeSettingSchema @schema={{schema}} @data={{data}} />
|
||||
</template>);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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!
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user