FEATURE: Support icon properties in objects settings schemas (#42632)

This commit is contained in:
Gabriel Grubba
2026-08-21 11:08:31 -03:00
committed by GitHub
parent 2a6180ad05
commit 28910d15a9
12 changed files with 268 additions and 9 deletions
@@ -39,7 +39,10 @@ DiscourseEvent.on(:site_setting_changed) do |name, old_value, new_value|
Scheduler::Defer.later("Null topic slug") { Topic.update_all(slug: nil) }
end
SvgSprite.expire_cache if name.to_s.include?("_icon")
if name.to_s.include?("_icon") ||
%i[icon objects].include?(SiteSetting.type_supervisor.get_type(name))
SvgSprite.expire_cache
end
SiteIconManager.ensure_optimized! if SiteIconManager::WATCHED_SETTINGS.include?(name)
+3
View File
@@ -238,6 +238,9 @@ en:
humanize_not_valid_upload_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid upload id."
not_valid_upload_value: "must be a valid upload id"
humanize_not_valid_icon_value: "The property at JSON Pointer '%{property_json_pointer}' must be an icon name."
not_valid_icon_value: "must be an icon name"
humanize_string_value_not_valid_min:
one: "The property at JSON Pointer '%{property_json_pointer}' must be at least %{count} character long."
other: "The property at JSON Pointer '%{property_json_pointer}' must be at least %{count} characters long."
@@ -85,6 +85,7 @@ The above schema definition states that the `link` object has a `name` property
- `categories`: Value of property is an array of valid category ids.
- `groups`: Value of property is an array of valid group ids.
- `tags`: Value of property is an array of valid tag names.
- `icon`: Value of property is the name of a single icon from the Discourse icon set. Selected icons are automatically added to the sprite sheet, so they can be rendered without being registered separately.
With the schema defined, the default value of the setting can now be set by defining a array in yaml like so:
@@ -7,6 +7,7 @@ import DatetimeField from "discourse/admin/components/schema-setting/types/datet
import EnumField from "discourse/admin/components/schema-setting/types/enum";
import FloatField from "discourse/admin/components/schema-setting/types/float";
import GroupsField from "discourse/admin/components/schema-setting/types/groups";
import IconField from "discourse/admin/components/schema-setting/types/icon";
import IntegerField from "discourse/admin/components/schema-setting/types/integer";
import StringField from "discourse/admin/components/schema-setting/types/string";
import TagsField from "discourse/admin/components/schema-setting/types/tags";
@@ -37,6 +38,8 @@ export default class SchemaSettingField extends Component {
return UploadField;
case "datetime":
return DatetimeField;
case "icon":
return IconField;
default:
throw new Error(`unknown type ${type}`);
}
@@ -0,0 +1,53 @@
/* eslint-disable ember/no-tracked-properties-from-args */
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import FieldInputDescription from "discourse/admin/components/schema-setting/field-input-description";
import { and, not } from "discourse/truth-helpers";
import DIconGridPicker from "discourse/ui-kit/d-icon-grid-picker";
import { i18n } from "discourse-i18n";
export default class SchemaSettingTypeIcon extends Component {
@tracked touched = false;
@tracked value = this.args.value;
required = this.args.spec.required;
@action
onChange(newValue) {
this.touched = true;
this.value = newValue;
this.args.onChange(newValue);
}
get validationErrorMessage() {
if (!this.touched) {
return;
}
if (!this.value && this.required) {
return i18n("admin.customize.schema.fields.required");
}
}
<template>
<DIconGridPicker
@value={{this.value}}
@onChange={{this.onChange}}
@allowClear={{not this.required}}
@showCaret={{true}}
@showSelectedName={{true}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
</div>
</template>
}
@@ -1,4 +1,4 @@
import { click, fillIn, findAll, render } from "@ember/test-helpers";
import { click, fillIn, findAll, render, waitFor } from "@ember/test-helpers";
import { module, test } from "qunit";
import AdminSchemaSettingEditor from "discourse/admin/components/schema-setting/editor";
import SiteSetting from "discourse/admin/models/site-setting";
@@ -8,6 +8,7 @@ import schemaAndData, {
SCHEMA_MODES,
} from "discourse/tests/fixtures/theme-setting-schema-data";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { i18n } from "discourse-i18n";
@@ -802,6 +803,68 @@ module(
assert.strictEqual(requiredEnumSelector.header().value(), "awesome");
});
test("input fields of type icon", async function (assert) {
pretender.get("/svg-sprite/picker-search", () =>
response(200, {
icons: [
{ id: "gamepad", name: "gamepad" },
{ id: "heart", name: "heart" },
],
has_more: false,
})
);
const setting = ThemeSettings.create({
setting: "objects_setting",
objects_schema: {
name: "something",
properties: {
icon_field: {
type: "icon",
},
required_icon_field: {
type: "icon",
required: true,
},
},
},
value: [{ required_icon_field: "heart" }],
});
await render(
<template>
<AdminSchemaSettingEditor
@id="1"
@setting={{setting}}
@schema={{setting.objects_schema}}
@routeToRedirect="adminCustomizeThemes.show"
/>
</template>
);
const inputFields = new InputFieldsFromDOM();
assert
.dom(
`${inputFields.fields.required_icon_field.selector} .d-icon-grid-picker`
)
.hasAttribute("data-value", "heart");
assert
.dom(`${inputFields.fields.icon_field.selector} .d-icon-grid-picker`)
.doesNotHaveAttribute("data-value");
await click(
`${inputFields.fields.icon_field.selector} .d-icon-grid-picker-trigger`
);
await waitFor("[data-icon-id='gamepad']");
await click("[data-icon-id='gamepad']");
assert
.dom(`${inputFields.fields.icon_field.selector} .d-icon-grid-picker`)
.hasAttribute("data-value", "gamepad");
});
test("input fields of type categories that is not required with min and max validations", async function (assert) {
const setting = ThemeSettings.create({
setting: "objects_setting",
+1 -1
View File
@@ -208,7 +208,7 @@ class SchemaSettingsObjectValidator
is_value_valid =
case type
when "string", "datetime"
when "string", "datetime", "icon"
value.is_a?(String)
when "integer", "topic", "post"
value.is_a?(Integer)
+18 -6
View File
@@ -534,8 +534,8 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
def self.settings_icons
get_set_cache("settings_icons") do
# includes svg_icon_subset, icon type settings, and any settings containing
# _icon (incl. plugin settings)
# includes svg_icon_subset, icon type settings, icon properties of objects
# type settings, and any settings containing _icon (incl. plugin settings)
site_setting_icons = []
SiteSetting.settings_hash.each do |key, value|
@@ -543,6 +543,9 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
if key.to_s.include?("_icon") || SiteSetting.type_supervisor.get_type(key) == :icon
site_setting_icons |= value.split("|")
elsif SiteSetting.type_supervisor.get_type(key) == :objects && value.present?
schema = SiteSetting.type_supervisor.type_hash(key)[:schema]
site_setting_icons |= objects_setting_icons(schema, JSON.parse(value)) if schema
end
end
@@ -576,10 +579,13 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
type_info = settings["theme_setting_type_info"] || {}
settings.each do |key, value|
next unless String === value
if key.to_s.include?("_icon") || type_info.dig(key, :type) == "icon"
theme_icon_settings |= value.split("|")
if String === value
if key.to_s.include?("_icon") || type_info.dig(key, :type) == "icon"
theme_icon_settings |= value.split("|")
end
elsif type_info.dig(key, :type) == "objects" && value.is_a?(Array)
schema = type_info.dig(key, :schema)
theme_icon_settings |= objects_setting_icons(schema, value) if schema
end
end
end
@@ -589,6 +595,12 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
theme_icon_settings
end
def self.objects_setting_icons(schema, objects)
SchemaSettingsObjectValidator.property_values_of_type(schema:, objects:, type: "icon").grep(
String,
)
end
def self.custom_icons(theme_id)
# Automatically register icons in sprites added via themes or plugins
custom_svgs(theme_id).keys
+10
View File
@@ -2,3 +2,13 @@ category:
reaction_flair:
type: icon
default: ""
reaction_list:
type: objects
default: []
schema:
name: reaction
properties:
name:
type: string
icon:
type: icon
@@ -1410,5 +1410,42 @@ RSpec.describe SchemaSettingsObjectValidator do
)
end
end
context "for icon properties" do
let(:schema) { { name: "section", properties: { icon_property: { type: "icon" } } } }
it "should not return any error messages when the value of the property is of type string" do
expect(
described_class.new(schema: schema, object: { icon_property: "heart" }).validate,
).to eq({})
end
it "should not return any error messages when the value is not present and it's not required in the schema" do
expect(described_class.new(schema: schema, object: {}).validate).to eq({})
end
it "should return the right hash of error messages when value of property is not present and it's required" do
schema = {
name: "section",
properties: {
icon_property: {
type: "icon",
required: true,
},
},
}
errors = described_class.new(schema: schema, object: {}).validate
expect(errors.keys).to eq(["/icon_property"])
expect(errors["/icon_property"].full_messages).to contain_exactly("must be present")
end
it "should return the right hash of error messages when value of property is not of type icon" do
errors = described_class.new(schema: schema, object: { icon_property: 1 }).validate
expect(errors.keys).to eq(["/icon_property"])
expect(errors["/icon_property"].full_messages).to contain_exactly("must be an icon name")
end
end
end
end
+35
View File
@@ -177,6 +177,41 @@ RSpec.describe SvgSprite do
expect(SvgSprite.all_icons).to include("dragon")
end
it "includes icons defined in icon properties of objects type theme settings" do
theme.set_field(target: :settings, name: :yaml, value: <<~YAML)
featured_links:
type: objects
default:
- title: link
icon: dragon
schema:
name: link
properties:
title:
type: string
icon:
type: icon
YAML
theme.save!
expect(SvgSprite.all_icons(theme.id)).to include("dragon")
theme.update_setting(:featured_links, [{ "title" => "link", "icon" => "gas-pump" }])
theme.save!
expect(SvgSprite.all_icons(theme.id)).to include("gas-pump")
expect(SvgSprite.all_icons(theme.id)).not_to include("dragon")
end
it "includes icons defined in icon properties of objects type site settings" do
SiteSetting.load_settings(Rails.root.join("spec/fixtures/site_settings/icon_settings.yml").to_s)
SiteSetting.reaction_list = [{ name: "party", icon: "dragon" }].to_json
SvgSprite.expire_cache
expect(SvgSprite.all_icons).to include("dragon")
end
it "includes icons defined in theme modifiers" do
child_theme = Fabricate(:theme, component: true)
theme.add_relative_theme!(:child, child_theme)
@@ -118,6 +118,45 @@ RSpec.describe "Admin editing objects type" do
)
end
it "allows an admin to pick an icon for an icon type property" do
SiteSetting.svg_icon_subset = "gamepad"
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
links_setting:
type: objects
default:
- title: link
icon: heart
schema:
name: link
properties:
title:
type: string
icon:
type: icon
YAML
theme.save!
visit("/admin/customize/themes/#{theme.id}")
admin_objects_theme_setting_editor =
admin_customize_themes_page.click_edit_objects_setting_button("links_setting")
icon_picker = PageObjects::Components::DIconGridPicker.new(".schema-field[data-name='icon']")
expect(icon_picker).to have_selected_icon("heart")
icon_picker.expand
icon_picker.filter("gamepad")
icon_picker.select_icon("gamepad")
admin_objects_theme_setting_editor.save
expect(theme.reload.settings[:links_setting].value).to eq(
[{ "title" => "link", "icon" => "gamepad" }],
)
end
it "allows an admin to edit a theme setting of objects type via the settings editor" do
visit "/admin/customize/themes/#{theme.id}"