DEV: Change category type to categories type for theme object schema (#26339)

Why this change?

This is a follow-up to 86b2e3aa3e.

Basically, we want to allow people to select more than 1 category as well.

What does this change do?

1. Change `type: category` to `type: categories` and support `min` and `max`
   validations for `type: categories`.

2. Fix the `<SchemaThemeSetting::Types::Categories>` component to support the
   `min` and `max` validations and switch it to use the `<CategorySelector>` component
   instead of the `<CategoryChooser>` component which only supports selecting one category.
This commit is contained in:
Alan Guo Xiang Tan 2024-03-27 10:54:30 +08:00 committed by GitHub
parent 0df50a7e5d
commit 476d91d233
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 387 additions and 100 deletions

View File

@ -377,6 +377,7 @@ export default class SchemaThemeSettingEditor extends Component {
@spec={{field.spec}} @spec={{field.spec}}
@onValueChange={{fn this.inputFieldChanged field}} @onValueChange={{fn this.inputFieldChanged field}}
@description={{field.description}} @description={{field.description}}
@setting={{@setting}}
/> />
{{/each}} {{/each}}
{{#if (gt this.fields.length 0)}} {{#if (gt this.fields.length 0)}}

View File

@ -2,7 +2,7 @@ import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking"; import { cached } from "@glimmer/tracking";
import htmlSafe from "discourse-common/helpers/html-safe"; import htmlSafe from "discourse-common/helpers/html-safe";
import BooleanField from "./types/boolean"; import BooleanField from "./types/boolean";
import CategoryField from "./types/category"; import CategoriesField from "./types/categories";
import EnumField from "./types/enum"; import EnumField from "./types/enum";
import FloatField from "./types/float"; import FloatField from "./types/float";
import GroupField from "./types/group"; import GroupField from "./types/group";
@ -25,8 +25,8 @@ export default class SchemaThemeSettingField extends Component {
return BooleanField; return BooleanField;
case "enum": case "enum":
return EnumField; return EnumField;
case "category": case "categories":
return CategoryField; return CategoriesField;
case "tags": case "tags":
return TagsField; return TagsField;
case "group": case "group":
@ -58,6 +58,7 @@ export default class SchemaThemeSettingField extends Component {
@spec={{@spec}} @spec={{@spec}}
@onChange={{@onValueChange}} @onChange={{@onValueChange}}
@description={{this.description}} @description={{this.description}}
@setting={{@setting}}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,64 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
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 CategorySelector from "select-kit/components/category-selector";
export default class SchemaThemeSettingTypeCategories extends Component {
@tracked touched = false;
@tracked
value =
this.args.value?.map((categoryId) => {
return this.args.setting.metadata.categories[categoryId];
}) || [];
required = this.args.spec.required;
min = this.args.spec.validations?.min;
max = this.args.spec.validations?.max;
@action
onInput(categories) {
this.touched = true;
this.value = categories;
this.args.onChange(categories.map((category) => category.id));
}
get validationErrorMessage() {
if (!this.touched) {
return;
}
if (
(this.min && this.value.length < this.min) ||
(this.required && (!this.value || this.value.length === 0))
) {
return I18n.t("admin.customize.theme.schema.fields.categories.at_least", {
count: this.min || 1,
});
}
}
<template>
<CategorySelector
@categories={{this.value}}
@onChange={{this.onInput}}
@options={{hash allowUncategorized=false maximum=this.max}}
/>
<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>
}

View File

@ -1,34 +0,0 @@
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 CategoryChooser from "select-kit/components/category-chooser";
export default class SchemaThemeSettingTypeCategory extends Component {
@tracked value = this.args.value;
required = this.args.spec.required;
@action
onInput(newVal) {
this.value = newVal;
this.args.onChange(newVal);
}
get categoryChooserOptions() {
return {
allowUncategorized: false,
none: !this.required,
clearable: !this.required,
};
}
<template>
<CategoryChooser
@value={{this.value}}
@onChange={{this.onInput}}
@options={{this.categoryChooserOptions}}
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -184,7 +184,7 @@ export default function schemaAndData(version = 1) {
choices: ["nice", "awesome", "cool"] choices: ["nice", "awesome", "cool"]
}, },
category_field: { category_field: {
type: "category", type: "categories",
}, },
group_field: { group_field: {
type: "group", type: "group",

View File

@ -229,6 +229,10 @@ export default function selectKit(selector) {
return filterHelper(query(selector).querySelector(".select-kit-filter")); return filterHelper(query(selector).querySelector(".select-kit-filter"));
}, },
error() {
return query(selector).querySelector(".select-kit-error");
},
rows() { rows() {
return query(selector).querySelectorAll(".select-kit-row"); return query(selector).querySelectorAll(".select-kit-row");
}, },

View File

@ -667,7 +667,7 @@ module(
assert.strictEqual(enumSelector.header().value(), "nice"); assert.strictEqual(enumSelector.header().value(), "nice");
}); });
test("input fields of type category", async function (assert) { test("input fields of type categories", async function (assert) {
const setting = ThemeSettings.create({ const setting = ThemeSettings.create({
setting: "objects_setting", setting: "objects_setting",
objects_schema: { objects_schema: {
@ -675,17 +675,29 @@ module(
identifier: "id", identifier: "id",
properties: { properties: {
required_category: { required_category: {
type: "category", type: "categories",
required: true, required: true,
}, },
not_required_category: { not_required_category: {
type: "category", type: "categories",
validations: {
min: 2,
max: 3,
},
},
},
},
metadata: {
categories: {
6: {
id: 6,
name: "some category",
}, },
}, },
}, },
value: [ value: [
{ {
required_category: 6, required_category: [6],
}, },
], ],
}); });
@ -706,9 +718,17 @@ module(
assert.strictEqual(categorySelector.header().value(), "6"); assert.strictEqual(categorySelector.header().value(), "6");
assert await categorySelector.expand();
.dom(categorySelector.clearButton()) await categorySelector.deselectItemByValue("6");
.doesNotExist("is not clearable"); await categorySelector.collapse();
inputFields.refresh();
assert.dom(inputFields.fields.required_category.errorElement).hasText(
I18n.t("admin.customize.theme.schema.fields.categories.at_least", {
count: 1,
})
);
assert assert
.dom(inputFields.fields.not_required_category.labelElement) .dom(inputFields.fields.not_required_category.labelElement)
@ -722,8 +742,24 @@ module(
await categorySelector.expand(); await categorySelector.expand();
await categorySelector.selectRowByIndex(1); await categorySelector.selectRowByIndex(1);
await categorySelector.collapse();
assert.dom(categorySelector.clearButton()).exists("is clearable"); inputFields.refresh();
assert.dom(inputFields.fields.not_required_category.errorElement).hasText(
I18n.t("admin.customize.theme.schema.fields.categories.at_least", {
count: 2,
})
);
await categorySelector.expand();
await categorySelector.selectRowByIndex(2);
await categorySelector.selectRowByIndex(3);
await categorySelector.selectRowByIndex(4);
assert
.dom(categorySelector.error())
.hasText("You can only select 3 items.");
}); });
test("input fields of type tags which is required", async function (assert) { test("input fields of type tags which is required", async function (assert) {

View File

@ -1,7 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
class ThemeObjectsSettingMetadataSerializer < ApplicationSerializer class ThemeObjectsSettingMetadataSerializer < ApplicationSerializer
attributes :property_descriptions attributes :categories, :property_descriptions
def categories
object
.categories(scope)
.reduce({}) do |acc, category|
acc[category.id] = BasicCategorySerializer.new(category, scope: scope, root: false).as_json
acc
end
end
def property_descriptions def property_descriptions
locales = {} locales = {}

View File

@ -5652,6 +5652,10 @@ en:
back_button: "Back to %{name}" back_button: "Back to %{name}"
fields: fields:
required: "*required" required: "*required"
categories:
at_least:
one: "at least %{count} category is required"
other: "at least %{count} categories are required"
tags: tags:
at_least: at_least:
one: "at least %{count} tag is required" one: "at least %{count} tag is required"

View File

@ -141,40 +141,58 @@ en:
required: "must be present" required: "must be present"
humanize_invalid_type: "The property at JSON Pointer '%{property_json_pointer}' must be of type %{type}." humanize_invalid_type: "The property at JSON Pointer '%{property_json_pointer}' must be of type %{type}."
invalid_type: "%{type} is not a valid type" invalid_type: "%{type} is not a valid type"
humanize_not_valid_string_value: "The property at JSON Pointer '%{property_json_pointer}' must be a string." humanize_not_valid_string_value: "The property at JSON Pointer '%{property_json_pointer}' must be a string."
not_valid_string_value: "must be a string" not_valid_string_value: "must be a string"
humanize_not_valid_integer_value: "The property at JSON Pointer '%{property_json_pointer}' must be an integer." humanize_not_valid_integer_value: "The property at JSON Pointer '%{property_json_pointer}' must be an integer."
not_valid_integer_value: "must be an integer" not_valid_integer_value: "must be an integer"
humanize_not_valid_float_value: "The property at JSON Pointer '%{property_json_pointer}' must be a float." humanize_not_valid_float_value: "The property at JSON Pointer '%{property_json_pointer}' must be a float."
not_valid_float_value: "must be a float" not_valid_float_value: "must be a float"
humanize_not_valid_boolean_value: "The property at JSON Pointer '%{property_json_pointer}' must be a boolean." humanize_not_valid_boolean_value: "The property at JSON Pointer '%{property_json_pointer}' must be a boolean."
not_valid_boolean_value: "must be a boolean" not_valid_boolean_value: "must be a boolean"
humanize_not_valid_enum_value: "The property at JSON Pointer '%{property_json_pointer}' must be one of the following %{choices}." humanize_not_valid_enum_value: "The property at JSON Pointer '%{property_json_pointer}' must be one of the following %{choices}."
not_valid_enum_value: "must be one of the following: %{choices}" not_valid_enum_value: "must be one of the following: %{choices}"
humanize_not_valid_category_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid category id."
not_valid_category_value: "must be a valid category id" humanize_not_valid_categories_value: "The property at JSON Pointer '%{property_json_pointer}' must be an array of valid category ids."
not_valid_categories_value: "must be an array of valid category ids"
humanize_categories_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must have at least %{min} category ids."
categories_value_not_valid_min: "must have at least %{min} category ids"
humanize_categories_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must have at most %{max} category ids."
categories_value_not_valid_max: "must have at most %{max} category ids"
humanize_not_valid_topic_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid topic id." humanize_not_valid_topic_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid topic id."
not_valid_topic_value: "must be a valid topic id" not_valid_topic_value: "must be a valid topic id"
humanize_not_valid_post_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid post id." humanize_not_valid_post_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid post id."
not_valid_post_value: "must be a valid post id" not_valid_post_value: "must be a valid post id"
humanize_not_valid_group_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid group id." humanize_not_valid_group_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid group id."
not_valid_group_value: "must be a valid group id" not_valid_group_value: "must be a valid group id"
humanize_not_valid_tags_value: "The property at JSON Pointer '%{property_json_pointer}' must be an array of valid tag names." humanize_not_valid_tags_value: "The property at JSON Pointer '%{property_json_pointer}' must be an array of valid tag names."
not_valid_tags_value: "must be an array of valid tag names" not_valid_tags_value: "must be an array of valid tag names"
humanize_tags_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must have at least %{min} tag names." humanize_tags_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must have at least %{min} tag names."
tags_value_not_valid_min: "must have at least %{min} tag names" tags_value_not_valid_min: "must have at least %{min} tag names"
humanize_tags_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must have at most %{max} tag names." humanize_tags_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must have at most %{max} tag names."
tags_value_not_valid_max: "must have at most %{max} tag names" tags_value_not_valid_max: "must have at most %{max} tag names"
humanize_not_valid_upload_value: "The property at JSON Pointer '%{property_json_pointer}' must be a valid upload id." 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" not_valid_upload_value: "must be a valid upload id"
humanize_string_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must be at least %{min} characters long." humanize_string_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must be at least %{min} characters long."
string_value_not_valid_min: "must be at least %{min} characters long" string_value_not_valid_min: "must be at least %{min} characters long"
humanize_string_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must be at most %{max} characters long." humanize_string_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must be at most %{max} characters long."
string_value_not_valid_max: "must be at most %{max} characters long" string_value_not_valid_max: "must be at most %{max} characters long"
humanize_number_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must be larger than or equal to %{min}." humanize_number_value_not_valid_min: "The property at JSON Pointer '%{property_json_pointer}' must be larger than or equal to %{min}."
number_value_not_valid_min: "must be larger than or equal to %{min}" number_value_not_valid_min: "must be larger than or equal to %{min}"
humanize_number_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must be smaller than or equal to %{max}." humanize_number_value_not_valid_max: "The property at JSON Pointer '%{property_json_pointer}' must be smaller than or equal to %{max}."
number_value_not_valid_max: "must be smaller than or equal to %{max}" number_value_not_valid_max: "must be smaller than or equal to %{max}"
humanize_string_value_not_valid_url: "The property at JSON Pointer '%{property_json_pointer}' must be a valid URL." humanize_string_value_not_valid_url: "The property at JSON Pointer '%{property_json_pointer}' must be a valid URL."
string_value_not_valid_url: "must be a valid URL" string_value_not_valid_url: "must be a valid URL"
locale_errors: locale_errors:

View File

@ -16,4 +16,21 @@ class ThemeSettingsManager::Objects < ThemeSettingsManager
def schema def schema
@opts[:schema] @opts[:schema]
end end
def categories(guardian)
category_ids = Set.new
value.each do |theme_setting_object|
category_ids.merge(
ThemeSettingsObjectValidator.new(
schema:,
object: theme_setting_object,
).property_values_of_type("categories"),
)
end
return [] if category_ids.empty?
Category.secured(guardian).where(id: category_ids)
end
end end

View File

@ -85,6 +85,10 @@ class ThemeSettingsObjectValidator
@errors @errors
end end
def property_values_of_type(type)
fetch_property_values_of_type(@properties, @object, type)
end
private private
def validate_child_objects(objects, property_name:, schema:) def validate_child_objects(objects, property_name:, schema:)
@ -120,7 +124,7 @@ class ThemeSettingsObjectValidator
case type case type
when "string" when "string"
value.is_a?(String) value.is_a?(String)
when "integer", "category", "topic", "post", "group", "upload" when "integer", "topic", "post", "group", "upload"
value.is_a?(Integer) value.is_a?(Integer)
when "float" when "float"
value.is_a?(Float) || value.is_a?(Integer) value.is_a?(Float) || value.is_a?(Integer)
@ -128,6 +132,8 @@ class ThemeSettingsObjectValidator
[true, false].include?(value) [true, false].include?(value)
when "enum" when "enum"
property_attributes[:choices].include?(value) property_attributes[:choices].include?(value)
when "categories"
value.is_a?(Array) && value.all? { |id| id.is_a?(Integer) }
when "tags" when "tags"
value.is_a?(Array) && value.all? { |tag| tag.is_a?(String) } value.is_a?(Array) && value.all? { |tag| tag.is_a?(String) }
else else
@ -151,24 +157,24 @@ class ThemeSettingsObjectValidator
return true if value.nil? return true if value.nil?
case type case type
when "topic", "category", "upload", "post", "group" when "topic", "upload", "post", "group"
if !valid_ids(type).include?(value) if !valid_ids(type).include?(value)
add_error(property_name, :"not_valid_#{type}_value") add_error(property_name, :"not_valid_#{type}_value")
return false return false
end end
when "tags" when "tags", "categories"
if !Array(value).to_set.subset?(valid_ids(type)) if !Array(value).to_set.subset?(valid_ids(type))
add_error(property_name, :"not_valid_#{type}_value") add_error(property_name, :"not_valid_#{type}_value")
return false return false
end end
if (min = validations&.dig(:min)) && value.length < min if (min = validations&.dig(:min)) && value.length < min
add_error(property_name, :tags_value_not_valid_min, min:) add_error(property_name, :"#{type}_value_not_valid_min", min:)
return false return false
end end
if (max = validations&.dig(:max)) && value.length > max if (max = validations&.dig(:max)) && value.length > max
add_error(property_name, :tags_value_not_valid_max, max:) add_error(property_name, :"#{type}_value_not_valid_max", max:)
return false return false
end end
when "string" when "string"
@ -225,7 +231,7 @@ class ThemeSettingsObjectValidator
end end
TYPE_TO_MODEL_MAP = { TYPE_TO_MODEL_MAP = {
"category" => { "categories" => {
klass: Category, klass: Category,
}, },
"topic" => { "topic" => {

View File

@ -32,4 +32,20 @@ objects_setting:
validations: validations:
max_length: 20 max_length: 20
url: url:
type: string type: string
objects_with_categories:
type: objects
default: []
schema:
name: categories
properties:
category_ids:
type: categories
child_categories:
type: objects
schema:
name: child category
properties:
category_ids:
type: categories

View File

@ -3,11 +3,11 @@
RSpec.describe ThemeSettingsManager::Objects do RSpec.describe ThemeSettingsManager::Objects do
fab!(:theme) fab!(:theme)
let(:objects_setting) do let(:theme_setting) do
yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml") yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml")
field = theme.set_field(target: :settings, name: "yaml", value: yaml) field = theme.set_field(target: :settings, name: "yaml", value: yaml)
theme.save! theme.save!
theme.settings[:objects_setting] theme.settings
end end
before { SiteSetting.experimental_objects_type_for_theme_settings = true } before { SiteSetting.experimental_objects_type_for_theme_settings = true }
@ -27,7 +27,7 @@ RSpec.describe ThemeSettingsManager::Objects do
}, },
] ]
objects_setting.value = new_value theme_setting[:objects_setting].value = new_value
expect(theme.reload.settings[:objects_setting].value).to eq(new_value) expect(theme.reload.settings[:objects_setting].value).to eq(new_value)
end end
@ -45,9 +45,42 @@ RSpec.describe ThemeSettingsManager::Objects do
}, },
] ]
expect { objects_setting.value = new_value }.to raise_error( expect { theme_setting[:objects_setting].value = new_value }.to raise_error(
Discourse::InvalidParameters, Discourse::InvalidParameters,
"The property at JSON Pointer '/0/links/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 at most 20 characters long.", "The property at JSON Pointer '/0/links/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 at most 20 characters long.",
) )
end end
describe "#categories" do
fab!(:category_1) { Fabricate(:category) }
fab!(:category_2) { Fabricate(:category) }
fab!(:category_3) { Fabricate(:private_category, group: Fabricate(:group)) }
fab!(:admin)
it "returns an empty array when there are no properties of `categories` type" do
expect(theme_setting[:objects_setting].categories(Guardian.new)).to eq([])
end
it "returns the categories record for all the properties of `categories` type in a flat array" do
new_value = [
{
"category_ids" => [category_1.id, category_2.id],
"child_categories" => [{ "category_ids" => [category_3.id] }],
},
]
theme_setting[:objects_with_categories].value = new_value
expect(theme.reload.settings[:objects_with_categories].value).to eq(new_value)
expect(theme.settings[:objects_with_categories].categories(Guardian.new)).to contain_exactly(
category_1,
category_2,
)
expect(
theme.settings[:objects_with_categories].categories(Guardian.new(admin)),
).to contain_exactly(category_1, category_2, category_3)
end
end
end end

View File

@ -15,7 +15,7 @@ RSpec.describe ThemeSettingsObjectValidator do
}, },
}, },
category_property: { category_property: {
type: "category", type: "categories",
required: true, required: true,
}, },
links: { links: {
@ -49,10 +49,10 @@ RSpec.describe ThemeSettingsObjectValidator do
objects: [ objects: [
{ {
title: "1234", title: "1234",
category_property: category.id, category_property: [category.id],
links: [{ position: 1, float: 4.5 }, { position: "string", float: 12 }], links: [{ position: 1, float: 4.5 }, { position: "string", float: 12 }],
}, },
{ title: "12345678910", category_property: 99_999_999, links: [{ float: 5 }] }, { title: "12345678910", category_property: [99_999_999], links: [{ float: 5 }] },
], ],
) )
@ -63,7 +63,7 @@ RSpec.describe ThemeSettingsObjectValidator do
"The property at JSON Pointer '/0/links/1/position' must be an integer.", "The property at JSON Pointer '/0/links/1/position' must be an integer.",
"The property at JSON Pointer '/0/links/1/float' must be smaller than or equal to 11.5.", "The property at JSON Pointer '/0/links/1/float' must be smaller than or equal to 11.5.",
"The property at JSON Pointer '/1/title' must be at most 10 characters long.", "The property at JSON Pointer '/1/title' must be at most 10 characters long.",
"The property at JSON Pointer '/1/category_property' must be a valid category id.", "The property at JSON Pointer '/1/category_property' must be an array of valid category ids.",
"The property at JSON Pointer '/1/links/0/position' must be present.", "The property at JSON Pointer '/1/links/0/position' must be present.",
"The property at JSON Pointer '/1/links/0/float' must be larger than or equal to 5.5.", "The property at JSON Pointer '/1/links/0/float' must be larger than or equal to 5.5.",
], ],
@ -1028,18 +1028,24 @@ RSpec.describe ThemeSettingsObjectValidator do
end end
context "for category properties" do context "for category properties" do
it "should not return any error message when the value of the property is a valid id of a category record" do fab!(:category_1) { Fabricate(:category) }
category = Fabricate(:category) fab!(:category_2) { Fabricate(:category) }
schema = { name: "section", properties: { category_property: { type: "category" } } } it "should not return any error message when the value of the property is an array of valid category ids" do
schema = { name: "section", properties: { category_property: { type: "categories" } } }
expect( expect(
described_class.new(schema: schema, object: { category_property: category.id }).validate, described_class.new(
schema: schema,
object: {
category_property: [category_1.id, category_2.id],
},
).validate,
).to eq({}) ).to eq({})
end end
it "should not return any error messages when the value is not present and it's not required in the schema" do it "should not return any error messages when the value is not present and it's not required in the schema" do
schema = { name: "section", properties: { category_property: { type: "category" } } } schema = { name: "section", properties: { category_property: { type: "categories" } } }
expect(described_class.new(schema: schema, object: {}).validate).to eq({}) expect(described_class.new(schema: schema, object: {}).validate).to eq({})
end end
@ -1048,7 +1054,7 @@ RSpec.describe ThemeSettingsObjectValidator do
name: "section", name: "section",
properties: { properties: {
category_property: { category_property: {
type: "category", type: "categories",
required: true, required: true,
}, },
}, },
@ -1059,30 +1065,51 @@ RSpec.describe ThemeSettingsObjectValidator do
expect(errors["/category_property"].full_messages).to contain_exactly("must be present") expect(errors["/category_property"].full_messages).to contain_exactly("must be present")
end end
it "should return the right hash of error messages when value of property is not an integer" do it "should return the right hash of error messages when value of property contains an array where not all values are integers" do
schema = { name: "section", properties: { category_property: { type: "category" } } } schema = { name: "section", properties: { category_property: { type: "categories" } } }
errors = errors =
described_class.new(schema: schema, object: { category_property: "string" }).validate described_class.new(schema: schema, object: { category_property: ["string"] }).validate
expect(errors.keys).to eq(["/category_property"]) expect(errors.keys).to eq(["/category_property"])
expect(errors["/category_property"].full_messages).to contain_exactly( expect(errors["/category_property"].full_messages).to contain_exactly(
"must be a valid category id", "must be an array of valid category ids",
) )
end end
it "should return the right hash of error messages when value of property is not a valid id of a category record" do it "should return the right hash of error messages when number of category ids does not satisfy min or max validations" do
category = Fabricate(:category)
schema = { schema = {
name: "section", name: "section",
properties: { properties: {
category_property: { category_property: {
type: "category", type: "categories",
validations: {
min: 1,
max: 2,
},
},
},
}
errors = described_class.new(schema: schema, object: { category_property: [] }).validate
expect(errors.keys).to eq(["/category_property"])
expect(errors["/category_property"].full_messages).to contain_exactly(
"must have at least 1 category ids",
)
end
it "should return the right hash of error messages when value of property is not an array of valid category ids" do
schema = {
name: "section",
properties: {
category_property: {
type: "categories",
}, },
category_property_2: { category_property_2: {
type: "category", type: "categories",
}, },
child_categories: { child_categories: {
type: "objects", type: "objects",
@ -1090,7 +1117,7 @@ RSpec.describe ThemeSettingsObjectValidator do
name: "child_category", name: "child_category",
properties: { properties: {
category_property_3: { category_property_3: {
type: "category", type: "categories",
}, },
}, },
}, },
@ -1098,36 +1125,34 @@ RSpec.describe ThemeSettingsObjectValidator do
}, },
} }
object = {
category_property: [99_999_999, category_1.id],
category_property_2: [99_999_999],
child_categories: [
{ category_property_3: [99_999_999, category_2.id] },
{ category_property_3: [category_2.id] },
],
}
queries = queries =
track_sql_queries do track_sql_queries do
errors = errors = described_class.new(schema:, object:).validate
described_class.new(
schema: schema,
object: {
category_property: 99_999_999,
category_property_2: 99_999_999,
child_categories: [
{ category_property_3: 99_999_999 },
{ category_property_3: category.id },
],
},
).validate
expect(errors.keys).to eq( expect(errors.keys).to eq(
%w[/category_property /category_property_2 /child_categories/0/category_property_3], %w[/category_property /category_property_2 /child_categories/0/category_property_3],
) )
expect(errors["/category_property"].full_messages).to contain_exactly( expect(errors["/category_property"].full_messages).to contain_exactly(
"must be a valid category id", "must be an array of valid category ids",
) )
expect(errors["/category_property_2"].full_messages).to contain_exactly( expect(errors["/category_property_2"].full_messages).to contain_exactly(
"must be a valid category id", "must be an array of valid category ids",
) )
expect( expect(
errors["/child_categories/0/category_property_3"].full_messages, errors["/child_categories/0/category_property_3"].full_messages,
).to contain_exactly("must be a valid category id") ).to contain_exactly("must be an array of valid category ids")
end end
# only 1 SQL query should be executed to check if category ids are valid # only 1 SQL query should be executed to check if category ids are valid

View File

@ -1390,6 +1390,35 @@ RSpec.describe Admin::ThemesController do
}, },
) )
end end
it "returns 200 with the right `categories` attribute for a theme setting with categories propertoes" do
category_1 = Fabricate(:category)
category_2 = Fabricate(:category)
category_3 = Fabricate(:category)
theme_setting[:objects_with_categories].value = [
{
"category_ids" => [category_1.id, category_2.id],
"child_categories" => [{ "category_ids" => [category_3.id] }],
},
]
get "/admin/themes/#{theme.id}/objects_setting_metadata/objects_with_categories.json"
expect(response.status).to eq(200)
categories = response.parsed_body["categories"]
expect(categories.keys.map(&:to_i)).to contain_exactly(
category_1.id,
category_2.id,
category_3.id,
)
expect(categories[category_1.id.to_s]["name"]).to eq(category_1.name)
expect(categories[category_2.id.to_s]["name"]).to eq(category_2.name)
expect(categories[category_3.id.to_s]["name"]).to eq(category_3.name)
end
end end
end end
end end

View File

@ -37,4 +37,58 @@ RSpec.describe ThemeObjectsSettingMetadataSerializer do
) )
end end
end end
describe "#categories" do
fab!(:category_1) { Fabricate(:category) }
fab!(:category_2) { Fabricate(:category) }
fab!(:category_3) { Fabricate(:private_category, group: Fabricate(:group)) }
fab!(:admin)
it "should return a hash of serialized categories" do
theme_setting[:objects_with_categories].value = [
{
"category_ids" => [category_1.id, category_2.id],
"child_categories" => [{ "category_ids" => [category_3.id] }],
},
]
scope = Guardian.new
payload =
described_class.new(theme_setting[:objects_with_categories], scope:, root: false).as_json
categories = payload[:categories]
expect(categories.keys).to contain_exactly(category_1.id, category_2.id)
expect(categories[category_1.id]).to eq(
BasicCategorySerializer.new(category_1, scope:, root: false).as_json,
)
expect(categories[category_2.id]).to eq(
BasicCategorySerializer.new(category_2, scope:, root: false).as_json,
)
scope = Guardian.new(admin)
payload =
described_class.new(theme_setting[:objects_with_categories], scope:, root: false).as_json
categories = payload[:categories]
expect(categories.keys).to contain_exactly(category_1.id, category_2.id, category_3.id)
expect(categories[category_1.id]).to eq(
BasicCategorySerializer.new(category_1, scope:, root: false).as_json,
)
expect(categories[category_2.id]).to eq(
BasicCategorySerializer.new(category_2, scope:, root: false).as_json,
)
expect(categories[category_3.id]).to eq(
BasicCategorySerializer.new(category_3, scope:, root: false).as_json,
)
end
end
end end

View File

@ -3,18 +3,18 @@
RSpec.describe ThemeSettingsSerializer do RSpec.describe ThemeSettingsSerializer do
fab!(:theme) fab!(:theme)
let(:objects_setting) do let(:theme_setting) do
yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml") yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml")
theme.set_field(target: :settings, name: "yaml", value: yaml) theme.set_field(target: :settings, name: "yaml", value: yaml)
theme.save! theme.save!
theme.settings[:objects_setting] theme.settings
end end
describe "#objects_schema" do before { SiteSetting.experimental_objects_type_for_theme_settings = true }
before { SiteSetting.experimental_objects_type_for_theme_settings = true }
describe "#objects_schema" do
it "should include the attribute when theme setting is typed objects" do it "should include the attribute when theme setting is typed objects" do
payload = ThemeSettingsSerializer.new(objects_setting).as_json payload = ThemeSettingsSerializer.new(theme_setting[:objects_setting]).as_json
expect(payload[:theme_settings][:objects_schema][:name]).to eq("section") expect(payload[:theme_settings][:objects_schema][:name]).to eq("section")
end end

View File

@ -113,6 +113,10 @@ RSpec.describe "Admin editing objects type theme setting", type: :system do
] ]
} }
] ]
},
{
"setting": "objects_with_categories",
"value": []
} }
] ]
SETTING SETTING