FEATURE: porting type object to site settings (#32706)

This commit is contained in:
Gabriel Grubba
2025-05-13 14:30:24 -03:00
committed by GitHub
parent c688554cdd
commit 4d99c839b6
43 changed files with 1049 additions and 212 deletions

View File

@ -8,10 +8,10 @@ import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { cloneJSON } from "discourse/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";
import Tree from "admin/components/schema-setting/editor/tree";
import FieldInput from "admin/components/schema-setting/field";
export default class SchemaThemeSettingNewEditor extends Component {
export default class SchemaSettingNewEditor extends Component {
@service router;
@tracked history = [];
@ -21,9 +21,8 @@ export default class SchemaThemeSettingNewEditor extends Component {
@tracked saveButtonDisabled = false;
@tracked validationErrorMessage;
inputFieldObserver = new Map();
data = cloneJSON(this.args.setting.value);
schema = this.args.setting.objects_schema;
schema = this.args.schema;
@action
onChildClick(index, propertyName, parentNodeIndex) {
@ -68,7 +67,7 @@ export default class SchemaThemeSettingNewEditor extends Component {
const lastHistory = this.history[this.history.length - 1];
return i18n("admin.customize.theme.schema.back_button", {
return i18n("admin.customize.schema.back_button", {
name: this.generateSchemaTitle(
this.#resolveDataFromPaths(lastHistory.dataPaths)[lastHistory.index],
this.#resolveSchemaFromPaths(lastHistory.schemaPaths),
@ -229,16 +228,11 @@ export default class SchemaThemeSettingNewEditor extends Component {
@action
saveChanges() {
this.saveButtonDisabled = true;
this.args.setting
.updateSetting(this.args.themeId, this.data)
.updateSetting(this.args.id, this.data)
.then((result) => {
this.args.setting.set("value", result[this.args.setting.setting]);
this.router.transitionTo(
"adminCustomizeThemes.show",
this.args.themeId
);
this.router.transitionTo(this.args.routeToRedirect, this.args.id);
})
.catch((e) => {
if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) {
@ -251,17 +245,17 @@ export default class SchemaThemeSettingNewEditor extends Component {
}
<template>
<div class="schema-theme-setting-editor">
<div class="schema-setting-editor">
{{#if this.validationErrorMessage}}
<div class="schema-theme-setting-editor__errors">
<div class="schema-setting-editor__errors">
<div class="alert alert-error">
{{this.validationErrorMessage}}
</div>
</div>
{{/if}}
<div class="schema-theme-setting-editor__wrapper">
<div class="schema-theme-setting-editor__navigation">
<div class="schema-setting-editor__wrapper">
<div class="schema-setting-editor__navigation">
<Tree
@data={{this.activeData}}
@schema={{this.activeSchema}}
@ -276,7 +270,7 @@ export default class SchemaThemeSettingNewEditor extends Component {
@registerInputFieldObserver={{this.registerInputFieldObserver}}
/>
<div class="schema-theme-setting-editor__footer">
<div class="schema-setting-editor__footer">
<DButton
@disabled={{this.saveButtonDisabled}}
@action={{this.saveChanges}}
@ -286,7 +280,7 @@ export default class SchemaThemeSettingNewEditor extends Component {
</div>
</div>
<div class="schema-theme-setting-editor__fields">
<div class="schema-setting-editor__fields">
{{#each this.fields as |field|}}
<FieldInput
@name={{field.name}}
@ -303,7 +297,7 @@ export default class SchemaThemeSettingNewEditor extends Component {
<DButton
@action={{this.removeItem}}
@icon="trash-can"
class="btn-danger schema-theme-setting-editor__remove-btn"
class="btn-danger schema-setting-editor__remove-btn"
/>
{{/if}}
</div>

View File

@ -4,11 +4,11 @@ import icon from "discourse/helpers/d-icon";
<template>
<li
role="link"
class="schema-theme-setting-editor__tree-node --child"
class="schema-setting-editor__tree-node --child"
...attributes
{{on "click" @onChildClick}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<div class="schema-setting-editor__tree-node-text">
<span>{{@generateSchemaTitle @object @schema @index}}</span>
{{icon "chevron-right"}}
</div>

View File

@ -5,9 +5,9 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import ChildTreeNode from "admin/components/schema-theme-setting/editor/child-tree-node";
import ChildTreeNode from "admin/components/schema-setting/editor/child-tree-node";
export default class SchemaThemeSettingNewEditorChildTree extends Component {
export default class SchemaSettingNewEditorChildTree extends Component {
@tracked expanded = true;
@action
@ -27,7 +27,7 @@ export default class SchemaThemeSettingNewEditorChildTree extends Component {
<template>
<div
class="schema-theme-setting-editor__tree-node --heading"
class="schema-setting-editor__tree-node --heading"
role="button"
{{on "click" this.toggleVisibility}}
>
@ -48,12 +48,12 @@ export default class SchemaThemeSettingNewEditorChildTree extends Component {
/>
{{/each}}
<li class="schema-theme-setting-editor__tree-node --child --add-button">
<li class="schema-setting-editor__tree-node --child --add-button">
<DButton
@action={{fn @addChildItem @name @parentNodeIndex}}
@translatedLabel={{@schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --child"
class="btn-transparent schema-setting-editor__tree-add-button --child"
data-test-parent-index={{@parentNodeIndex}}
/>
</li>

View File

@ -6,9 +6,9 @@ import { gt } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { bind } from "discourse/lib/decorators";
import ChildTree from "admin/components/schema-theme-setting/editor/child-tree";
import ChildTree from "admin/components/schema-setting/editor/child-tree";
export default class SchemaThemeSettingNewEditorTreeNode extends Component {
export default class SchemaSettingNewEditorTreeNode extends Component {
@tracked text;
childObjectsProperties = this.findChildObjectsProperties(
@ -52,11 +52,11 @@ export default class SchemaThemeSettingNewEditorTreeNode extends Component {
{{on "click" @onClick}}
role="link"
class={{concatClass
"schema-theme-setting-editor__tree-node --parent"
"schema-setting-editor__tree-node --parent"
(if @active "--active")
}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<div class="schema-setting-editor__tree-node-text">
<span>{{this.text}}</span>
{{#if (gt this.childObjectsProperties.length 0)}}

View File

@ -3,17 +3,17 @@ import { on } from "@ember/modifier";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import TreeNode from "admin/components/schema-theme-setting/editor/tree-node";
import TreeNode from "admin/components/schema-setting/editor/tree-node";
<template>
<ul class="schema-theme-setting-editor__tree">
<ul class="schema-setting-editor__tree">
{{#if @backButtonText}}
<li
role="link"
class="schema-theme-setting-editor__tree-node --back-btn"
class="schema-setting-editor__tree-node --back-btn"
{{on "click" @clickBack}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<div class="schema-setting-editor__tree-node-text">
{{icon "arrow-left"}}
{{@backButtonText}}
</div>
@ -34,12 +34,12 @@ import TreeNode from "admin/components/schema-theme-setting/editor/tree-node";
/>
{{/each}}
<li class="schema-theme-setting-editor__tree-node --parent --add-button">
<li class="schema-setting-editor__tree-node --parent --add-button">
<DButton
@action={{@addItem}}
@translatedLabel={{@schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --root"
class="btn-transparent schema-setting-editor__tree-add-button --root"
/>
</li>
</ul>

View File

@ -1,16 +1,16 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { htmlSafe } from "@ember/template";
import BooleanField from "admin/components/schema-theme-setting/types/boolean";
import CategoriesField from "admin/components/schema-theme-setting/types/categories";
import EnumField from "admin/components/schema-theme-setting/types/enum";
import FloatField from "admin/components/schema-theme-setting/types/float";
import GroupsField from "admin/components/schema-theme-setting/types/groups";
import IntegerField from "admin/components/schema-theme-setting/types/integer";
import StringField from "admin/components/schema-theme-setting/types/string";
import TagsField from "admin/components/schema-theme-setting/types/tags";
import BooleanField from "admin/components/schema-setting/types/boolean";
import CategoriesField from "admin/components/schema-setting/types/categories";
import EnumField from "admin/components/schema-setting/types/enum";
import FloatField from "admin/components/schema-setting/types/float";
import GroupsField from "admin/components/schema-setting/types/groups";
import IntegerField from "admin/components/schema-setting/types/integer";
import StringField from "admin/components/schema-setting/types/string";
import TagsField from "admin/components/schema-setting/types/tags";
export default class SchemaThemeSettingField extends Component {
export default class SchemaSettingField extends Component {
get component() {
const type = this.args.spec.type;

View File

@ -5,9 +5,9 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { and, not } from "truth-helpers";
import { i18n } from "discourse-i18n";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
export default class SchemaThemeSettingNumberField extends Component {
export default class SchemaSettingNumberField extends Component {
@tracked touched = false;
@tracked value = this.args.value;
min = this.args.spec.validations?.min;
@ -43,20 +43,20 @@ export default class SchemaThemeSettingNumberField extends Component {
if (!this.value) {
if (this.required) {
return i18n("admin.customize.theme.schema.fields.required");
return i18n("admin.customize.schema.fields.required");
} else {
return;
}
}
if (this.min && this.value < this.min) {
return i18n("admin.customize.theme.schema.fields.number.too_small", {
return i18n("admin.customize.schema.fields.number.too_small", {
count: this.min,
});
}
if (this.max && this.value > this.max) {
return i18n("admin.customize.theme.schema.fields.number.too_large", {
return i18n("admin.customize.schema.fields.number.too_large", {
count: this.max,
});
}

View File

@ -2,9 +2,9 @@ import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
export default class SchemaThemeSettingTypeBoolean extends Component {
export default class SchemaSettingTypeBoolean extends Component {
@action
onInput(event) {
this.args.onChange(event.currentTarget.checked);

View File

@ -1,11 +1,11 @@
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { and, not } from "truth-helpers";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import SchemaThemeSettingTypeModels from "admin/components/schema-theme-setting/types/models";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
import SchemaSettingTypeModels from "admin/components/schema-setting/types/models";
import CategorySelector from "select-kit/components/category-selector";
export default class SchemaThemeSettingTypeCategories extends SchemaThemeSettingTypeModels {
export default class SchemaSettingTypeCategories extends SchemaSettingTypeModels {
@tracked
value =
this.args.value?.map((categoryId) => {

View File

@ -1,10 +1,10 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
import ComboBox from "select-kit/components/combo-box";
export default class SchemaThemeSettingTypeEnum extends Component {
export default class SchemaSettingTypeEnum extends Component {
@tracked
value =
this.args.value || (this.args.spec.required && this.args.spec.default);

View File

@ -0,0 +1,9 @@
import SchemaSettingNumberField from "admin/components/schema-setting/number-field";
export default class SchemaSettingTypeFloat extends SchemaSettingNumberField {
step = 0.1;
parseValue(value) {
return parseFloat(value);
}
}

View File

@ -1,10 +1,10 @@
import { service } from "@ember/service";
import { and, not } from "truth-helpers";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import SchemaThemeSettingTypeModels from "admin/components/schema-theme-setting/types/models";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
import SchemaSettingTypeModels from "admin/components/schema-setting/types/models";
import GroupChooser from "select-kit/components/group-chooser";
export default class SchemaThemeSettingTypeGroups extends SchemaThemeSettingTypeModels {
export default class SchemaSettingTypeGroups extends SchemaSettingTypeModels {
@service site;
type = "groups";

View File

@ -0,0 +1,10 @@
import SchemaSettingNumberField from "admin/components/schema-setting/number-field";
export default class SchemaSettingTypeInteger extends SchemaSettingNumberField {
inputMode = "numeric";
pattern = "[0-9]*";
parseValue(value) {
return parseInt(value, 10);
}
}

View File

@ -4,7 +4,7 @@ import { action } from "@ember/object";
import { isBlank } from "@ember/utils";
import { i18n } from "discourse-i18n";
export default class SchemaThemeSettingTypeModels extends Component {
export default class SchemaSettingTypeModels extends Component {
@tracked value = this.args.value;
required = this.args.spec.required;
@ -33,7 +33,7 @@ export default class SchemaThemeSettingTypeModels extends Component {
(this.min && this.value && this.value.length < this.min) ||
(this.required && isValueBlank)
) {
return i18n(`admin.customize.theme.schema.fields.${this.type}.at_least`, {
return i18n(`admin.customize.schema.fields.${this.type}.at_least`, {
count: this.min || 1,
});
}

View File

@ -6,9 +6,9 @@ import { action } from "@ember/object";
import { and, not } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
export default class SchemaThemeSettingTypeString extends Component {
export default class SchemaSettingTypeString extends Component {
@tracked touched = false;
@tracked value = this.args.value || "";
minLength = this.args.spec.validations?.min_length;
@ -32,14 +32,14 @@ export default class SchemaThemeSettingTypeString extends Component {
if (valueLength === 0) {
if (this.required) {
return i18n("admin.customize.theme.schema.fields.required");
return i18n("admin.customize.schema.fields.required");
} else {
return;
}
}
if (this.minLength && valueLength < this.minLength) {
return i18n("admin.customize.theme.schema.fields.string.too_short", {
return i18n("admin.customize.schema.fields.string.too_short", {
count: this.minLength,
});
}

View File

@ -1,9 +1,9 @@
import { and, not } from "truth-helpers";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import SchemaThemeSettingTypeModels from "admin/components/schema-theme-setting/types/models";
import FieldInputDescription from "admin/components/schema-setting/field-input-description";
import SchemaSettingTypeModels from "admin/components/schema-setting/types/models";
import TagChooser from "select-kit/components/tag-chooser";
export default class SchemaThemeSettingTypeTags extends SchemaThemeSettingTypeModels {
export default class SchemaSettingTypeTags extends SchemaSettingTypeModels {
type = "tags";
get tagChooserOption() {

View File

@ -1,9 +0,0 @@
import SchemaThemeSettingNumberField from "admin/components/schema-theme-setting/number-field";
export default class SchemaThemeSettingTypeFloat extends SchemaThemeSettingNumberField {
step = 0.1;
parseValue(value) {
return parseFloat(value);
}
}

View File

@ -1,10 +0,0 @@
import SchemaThemeSettingNumberField from "admin/components/schema-theme-setting/number-field";
export default class SchemaThemeSettingTypeInteger extends SchemaThemeSettingNumberField {
inputMode = "numeric";
pattern = "[0-9]*";
parseValue(value) {
return parseInt(value, 10);
}
}

View File

@ -165,6 +165,14 @@ export default Mixin.create({
label: "admin.site_settings.json_schema.edit",
icon: "pencil",
};
} else if (setting.schema) {
return {
action: () => {
this.router.transitionTo("admin.schema", setting.setting);
},
label: "admin.site_settings.json_schema.edit",
icon: "pencil",
};
} else if (setting.objects_schema) {
return {
action: () => {

View File

@ -31,7 +31,6 @@ export default class SiteSetting extends EmberObject {
}
categories[s.category].pushObject(SiteSetting.create(s));
});
return Object.keys(categories).map(function (n) {
return {
nameKey: n,
@ -43,6 +42,17 @@ export default class SiteSetting extends EmberObject {
);
}
static findByName(name) {
return ajax("/admin/site_settings", {
data: {
names: [name],
},
}).then(function (settings) {
const setting = settings.site_settings.find((s) => s.setting === name);
return SiteSetting.create(setting);
});
}
static update(key, value, opts = {}) {
const data = {};
data[key] = value;

View File

@ -392,7 +392,7 @@ export default function () {
);
}
);
this.route("schema", { path: "schema/:setting_name" });
this.route(
"adminPlugins",
{ path: "/plugins", resetNamespace: true },

View File

@ -0,0 +1,32 @@
import Route from "@ember/routing/route";
import { service } from "@ember/service";
import SiteSetting from "admin/models/site-setting";
export default class AdminSchemaRoute extends Route {
@service routeHistory;
async model(params) {
const setting = await SiteSetting.findByName(params.setting_name);
try {
setting.value = JSON.parse(setting.value);
} catch (e) {
// eslint-disable-next-line no-console
console.error(
`Failed to parse plugin setting ${setting.setting} value: ${setting.value}`,
e
);
setting.value = {};
}
setting.updateSetting = (settingName, value) => {
return SiteSetting.update(settingName, JSON.stringify(value));
};
return {
setting,
settingName: params.setting_name,
goBackUrl: this.routeHistory.lastURL,
};
}
}

View File

@ -0,0 +1,25 @@
import { hash } from "@ember/helper";
import RouteTemplate from "ember-route-template";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import Editor from "admin/components/schema-setting/editor";
export default RouteTemplate(
<template>
<div class="customize-show-schema__header row">
<a href={{@model.goBackUrl}}>
{{icon "arrow-left"}}
</a>
<h2>
{{i18n "admin.customize.schema.title" (hash name=@model.settingName)}}
</h2>
</div>
<Editor
@id={{@model.settingName}}
@routeToRedirect={{@model.goBackUrl}}
@schema={{@model.setting.schema}}
@setting={{@model.setting}}
/>
</template>
);

View File

@ -3,26 +3,31 @@ import { LinkTo } from "@ember/routing";
import RouteTemplate from "ember-route-template";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import Editor from "admin/components/schema-theme-setting/editor";
import Editor from "admin/components/schema-setting/editor";
export default RouteTemplate(
<template>
<div class="customize-themes-show-schema__header row">
<div class="customize-show-schema__header row">
<LinkTo
@route="adminCustomizeThemes.show"
@model={{@model.theme.id}}
class="btn-transparent customize-themes-show-schema__back"
class="btn-transparent customize-show-schema__back"
>
{{icon "arrow-left"}}{{@model.theme.name}}
</LinkTo>
<h2>
{{i18n
"admin.customize.theme.schema.title"
"admin.customize.schema.title"
(hash name=@model.setting.setting)
}}
</h2>
</div>
<Editor @themeId={{@model.theme.id}} @setting={{@model.setting}} />
<Editor
@id={{@model.theme.id}}
@routeToRedirect="adminCustomizeThemes.show"
@schema={{@model.setting.objects_schema}}
@setting={{@model.setting}}
/>
</template>
);

View File

@ -1,6 +1,10 @@
import ThemeSettings from "admin/models/theme-settings";
export default function schemaAndData(version = 1) {
import SiteSetting from "admin/models/site-setting";
export const SCHEMA_MODES = {
THEME: "theme",
SITE_SETTING: "SITE_SETTING",
};
export default function schemaAndData(version = 1, mode = SCHEMA_MODES.THEME) {
let schema, data;
if (version === 1) {
@ -206,6 +210,14 @@ export default function schemaAndData(version = 1) {
throw new Error("unknown fixture version");
}
if (mode === SCHEMA_MODES.SITE_SETTING) {
return SiteSetting.create({
schema: schema,
value: data,
setting: "objects_setting"
})
}
return ThemeSettings.create({
objects_schema: schema,
value: data,

View File

@ -1275,8 +1275,7 @@ a.inline-editable-field {
@import "admin/admin_section_landing_page";
@import "admin/admin_intro";
@import "admin/mini_profiler";
@import "admin/schema_theme_setting_editor";
@import "admin/customize_themes_show_schema";
@import "admin/schema_setting_editor";
@import "admin/admin_bulk_users_delete_modal";
@import "admin/color-palette-editor";
@import "admin/admin_config_color_palettes";

View File

@ -1,4 +1,4 @@
.customize-themes-show-schema {
.customize-show-schema {
&__header {
margin-bottom: 1em;
}

View File

@ -13,7 +13,7 @@
gap: 0 1em;
}
.schema-theme-setting-editor__navigation {
.schema-setting-editor__navigation {
overflow: hidden;
align-self: start;
@ -21,7 +21,7 @@
list-style: none;
}
.schema-theme-setting-editor__tree {
.schema-setting-editor__tree {
border: 1px solid var(--primary-low);
overflow: auto;
margin: 0 0 2em 0;
@ -52,7 +52,7 @@
}
}
.schema-theme-setting-editor__tree-node.--back-btn {
.schema-setting-editor__tree-node.--back-btn {
cursor: pointer;
width: 100%;
border-bottom: 1px solid var(--primary-low);
@ -63,7 +63,7 @@
background: var(--primary-very-low);
}
.schema-theme-setting-editor__tree-node-text {
.schema-setting-editor__tree-node-text {
color: currentcolor;
.d-icon {
@ -74,7 +74,7 @@
}
}
.schema-theme-setting-editor__tree-node-text {
.schema-setting-editor__tree-node-text {
padding: var(--schema-space);
color: var(--primary);
display: flex;
@ -91,11 +91,11 @@
}
}
.schema-theme-setting-editor__tree-node {
.schema-setting-editor__tree-node {
cursor: pointer;
&.--active {
> .schema-theme-setting-editor__tree-node-text {
> .schema-setting-editor__tree-node-text {
background-color: var(--tertiary);
color: var(--secondary);
@ -137,14 +137,14 @@
margin-left: var(--schema-space);
border-left: 1px solid var(--primary-200);
.schema-theme-setting-editor__tree-node-text {
.schema-setting-editor__tree-node-text {
color: var(--primary-800);
}
}
}
}
.schema-theme-setting-editor__tree-add-button {
.schema-setting-editor__tree-add-button {
color: var(--tertiary);
width: 100%;
line-height: 1.4; // match li height

View File

@ -6,13 +6,13 @@ class Admin::SiteSettingsController < Admin::AdminController
end
def index
params.permit(:categories, :plugin)
params.permit(:categories, :plugin, :names)
render_json_dump(
site_settings:
SiteSetting.all_settings(
filter_categories: params[:categories],
filter_plugin: params[:plugin],
filter_names: params[:names],
),
)
end

View File

@ -6702,31 +6702,31 @@ en:
active_filter: "Active"
inactive_filter: "Inactive"
updates_available_filter: "Updates Available"
schema:
title: "Edit %{name} setting"
back_button: "Back to %{name}"
fields:
required: "*required"
groups:
at_least:
one: "at least %{count} group is required"
other: "at least %{count} groups are required"
categories:
at_least:
one: "at least %{count} category is required"
other: "at least %{count} categories are required"
tags:
at_least:
one: "at least %{count} tag is required"
other: "at least %{count} tags are required"
string:
too_short:
one: "must be at least %{count} character"
other: "must be at least %{count} characters"
number:
too_small: "must be greater than or equal to %{count}"
too_large: "must be less than or equal to %{count}"
schema:
title: "Edit %{name} setting"
back_button: "Back to %{name}"
fields:
required: "*required"
groups:
at_least:
one: "at least %{count} group is required"
other: "at least %{count} groups are required"
categories:
at_least:
one: "at least %{count} category is required"
other: "at least %{count} categories are required"
tags:
at_least:
one: "at least %{count} tag is required"
other: "at least %{count} tags are required"
string:
too_short:
one: "must be at least %{count} character"
other: "must be at least %{count} characters"
number:
too_small: "must be greater than or equal to %{count}"
too_large: "must be less than or equal to %{count}"
colors:
select_base:
title: "Select base color palette"

View File

@ -2790,6 +2790,7 @@ en:
one: "Must be no more than %{count} character."
other: "Must be no more than %{count} characters."
invalid_json: "Invalid JSON."
invalid_object: "Invalid object."
invalid_reply_by_email_address: "Value must contain '%{reply_key}' and be different from the notification email."
invalid_alternative_reply_by_email_addresses: "All values must contain '%{reply_key}' and be different from the notification email."
invalid_domain_hostname: "Must not include * or ? characters."

View File

@ -102,6 +102,7 @@ Discourse::Application.routes.draw do
namespace :admin, constraints: StaffConstraint.new do
get "" => "admin#index"
get "search" => "search#index"
get "schema/:setting_name" => "admin#index"
get "plugins" => "plugins#index"
get "plugins/:plugin_id" => "plugins#show"

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ThemeSettingsObjectValidator
class SchemaSettingsObjectValidator
class << self
def validate_objects(schema:, objects:)
error_messages = []
@ -25,13 +25,13 @@ class ThemeSettingsObjectValidator
end
end
class ThemeSettingsObjectErrors
class SchemaSettingsObjectErrors
def initialize
@errors = []
end
def add_error(error, i18n_opts = {})
@errors << ThemeSettingsObjectError.new(error, i18n_opts)
@errors << SchemaSettingsObjectError.new(error, i18n_opts)
end
def humanize_messages(property_json_pointer)
@ -42,7 +42,7 @@ class ThemeSettingsObjectValidator
@errors.map(&:error_message)
end
end
class ThemeSettingsObjectError
class SchemaSettingsObjectError
def initialize(error, i18n_opts = {})
@error = error
@i18n_opts = i18n_opts
@ -218,7 +218,7 @@ class ThemeSettingsObjectValidator
def add_error(property_name, key, i18n_opts = {})
pointer = json_pointer(property_name)
@errors[pointer] ||= ThemeSettingsObjectErrors.new
@errors[pointer] ||= SchemaSettingsObjectErrors.new
@errors[pointer].add_error(key, i18n_opts)
end

View File

@ -20,9 +20,10 @@ class SiteSettings::TypeSupervisor
list_type
textarea
json_schema
schema
requires_confirmation
].freeze
VALIDATOR_OPTS = %i[min max regex hidden regex_error json_schema].freeze
VALIDATOR_OPTS = %i[min max regex hidden regex_error json_schema schema].freeze
# For plugins, so they can tell if a feature is supported
SUPPORTED_TYPES = %i[email username list enum].freeze
@ -59,6 +60,7 @@ class SiteSettings::TypeSupervisor
html_deprecated: 25,
tag_group_list: 26,
file_size_restriction: 27,
objects: 28,
)
end
@ -94,6 +96,7 @@ class SiteSettings::TypeSupervisor
@list_type = {}
@textareas = {}
@json_schemas = {}
@schemas = {}
end
def load_setting(name_arg, opts = {})
@ -102,6 +105,7 @@ class SiteSettings::TypeSupervisor
@textareas[name] = opts[:textarea] if opts[:textarea]
@json_schemas[name] = opts[:json_schema].constantize if opts[:json_schema]
@schemas[name] = opts[:schema] if opts[:schema]
if (enum = opts[:enum])
@enums[name] = enum.is_a?(String) ? enum.constantize : enum
@ -125,6 +129,11 @@ class SiteSettings::TypeSupervisor
@allow_any[name] = opts[:allow_any] == false ? false : true
@list_type[name] = opts[:list_type] if opts[:list_type]
end
# add validator for objects
if type.to_sym == :objects
@validators[name] = { class: ObjectsSettingValidator, opts: { schema: opts[:schema] } }
end
end
@types[name] = get_data_type(name, @defaults_provider[name])
@ -140,7 +149,6 @@ class SiteSettings::TypeSupervisor
name = name.to_sym
@types[name] = (@types[name] || get_data_type(name, value))
type = (override_type || @types[name])
case type
when self.class.types[:float]
value.to_f
@ -172,7 +180,6 @@ class SiteSettings::TypeSupervisor
def type_hash(name)
name = name.to_sym
type = get_type(name)
result = { type: type.to_s }
if type == :enum
@ -202,6 +209,8 @@ class SiteSettings::TypeSupervisor
result[:choices] = @choices[name] if @choices.has_key? name
result[:list_type] = @list_type[name] if @list_type.has_key? name
result[:textarea] = @textareas[name] if @textareas.has_key? name
result[:schema] = @schemas[name] if @schemas.has_key? name
if @json_schemas.has_key?(name) && json_klass = json_schema_class(name)
result[:json_schema] = json_klass.schema
end

View File

@ -26,7 +26,7 @@ class ThemeSettingsManager::Objects < ThemeSettingsManager
value.each do |theme_setting_object|
category_ids.merge(
ThemeSettingsObjectValidator.new(
SchemaSettingsObjectValidator.new(
schema:,
object: theme_setting_object,
).property_values_of_type("categories"),

View File

@ -53,7 +53,7 @@ class ThemeSettingsValidator
)
when types[:objects]
errors.concat(
ThemeSettingsObjectValidator.validate_objects(schema: opts[:schema], objects: value),
SchemaSettingsObjectValidator.validate_objects(schema: opts[:schema], objects: value),
)
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ObjectsSettingValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(val)
parsed_value = val.is_a?(String) ? JSON.parse(val) : val
if parsed_value.nil? || !parsed_value.is_a?(Array)
@error = I18n.t("site_settings.errors.invalid_object")
return false
end
errors =
SchemaSettingsObjectValidator.validate_objects(schema: @opts[:schema], objects: parsed_value)
if errors.empty?
@error = nil
true
else
@error = errors.map(&:full_messages).flatten.join(", ")
false
end
rescue StandardError
@error = I18n.t("site_settings.errors.invalid_object")
false
end
def error_message
@error
end
end

View File

@ -186,6 +186,20 @@ RSpec.describe SiteSettings::TypeSupervisor do
"[{\"name\":\"Brett\"}]",
json_schema: "TestJsonSchemaClass",
)
settings.setting(
:type_objects,
"[]",
type: "objects",
schema: {
name: "link",
properties: {
name: {
type: "string",
required: true,
},
},
},
)
settings.refresh!
end
@ -327,6 +341,30 @@ RSpec.describe SiteSettings::TypeSupervisor do
SiteSetting.types[:string],
]
end
it "raises when an object is not valid for the given schema" do
expect {
settings.type_supervisor.to_db_value(:type_objects, "not-json")
}.to raise_error Discourse::InvalidParameters
end
it "raises when an object property is not valid for the given schema" do
expect {
settings.type_supervisor.to_db_value(:type_objects, "[{\"nam\":\"Brett\"}]")
}.to raise_error Discourse::InvalidParameters
end
it "raises when an object value is not valid for the given schema" do
expect {
settings.type_supervisor.to_db_value(:type_objects, "[{\"name\":1}]")
}.to raise_error Discourse::InvalidParameters
end
it "returns value for the given objects schema string setting" do
expect(
settings.type_supervisor.to_db_value(:type_objects, "[{\"name\":\"Brett\"}]"),
).to eq ["[{\"name\":\"Brett\"}]", SiteSetting.types[:objects]]
end
end
describe "#to_rb_value" do
@ -446,6 +484,19 @@ RSpec.describe SiteSettings::TypeSupervisor do
settings.setting(:type_enum_choices, "2", type: "enum", choices: %w[1 2])
settings.setting(:type_enum_class, "a", enum: "TestEnumClass2")
settings.setting(:type_list, "a", type: "list", choices: %w[a b], list_type: "compact")
settings.setting(
:type_objects,
"[]",
type: "objects",
schema: {
name: "link",
properties: {
name: {
type: "string",
},
},
},
)
settings.refresh!
end
@ -501,6 +552,12 @@ RSpec.describe SiteSettings::TypeSupervisor do
expect(hash[:translate_names]).to eq false
end
it "returns objects type" do
hash = settings.type_supervisor.type_hash(:type_objects)
expect(hash[:type]).to eq "objects"
expect(hash[:schema]).to eq({ name: "link", properties: { name: { type: "string" } } })
end
it "returns int min/max values" do
expect(settings.type_supervisor.type_hash(:type_int)[:min]).to eq(-10)
expect(settings.type_supervisor.type_hash(:type_int)[:max]).to eq(10)

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe ThemeSettingsObjectValidator do
RSpec.describe SchemaSettingsObjectValidator do
describe ".validate_objects" do
it "should return the right array of humanized error messages for objects that are invalid" do
schema = {

View File

@ -112,7 +112,7 @@ RSpec.describe "Admin editing objects type theme setting", type: :system do
.fill_in_field("name", "")
.save
expect(find(".schema-theme-setting-editor__errors")).to have_text(
expect(find(".schema-setting-editor__errors")).to have_text(
"The property at JSON Pointer '/0/name' must be present. The property at JSON Pointer '/1/name' must be present. The property at JSON Pointer '/1/links/0/name' must be present.",
)
end

View File

@ -22,7 +22,7 @@ module PageObjects
def click_link(name, child: false)
find(
".schema-theme-setting-editor__navigation .schema-theme-setting-editor__tree-node#{child ? ".--child" : ".--parent"}",
".schema-setting-editor__navigation .schema-setting-editor__tree-node#{child ? ".--child" : ".--parent"}",
text: name,
).click
@ -44,7 +44,7 @@ module PageObjects
end
def back
find(".customize-themes-show-schema__back").click
find(".customize-show-schema__back").click
self
end