From a78d549d85b23a136ada604d7f578335808afe6b Mon Sep 17 00:00:00 2001 From: Luke Hickin Date: Tue, 28 Jul 2026 16:36:09 -0700 Subject: [PATCH] Add setting option icons and number bounds to the setting schema Adds the new setting kinds from issues-merchant-workflows issue 2669: - `settingIcon`: an enum of the 64 supported snake_case stable icon IDs, with an error message that points merchants and theme developers away from Polaris component names (`LayoutColumns3Icon`) and kebab-case admin handles (`layout-columns-3`). - `selectOptions`: an optional `icon` on each option. - `radioOptions`: a new definition for `radio` settings that rejects `icon` with a message pointing at `select`, so radio settings no longer silently accept an unsupported attribute. The shared `options` definition is unchanged. - `numberOptions`: a new definition for suggested `value`/`label`/`icon` entries on `number` settings, where `value` is a required number with at most one decimal digit. - `number`: new `min`, `max`, `icon` and `options` attributes, and one decimal digit of precision on `default`, `min` and `max`. The icon list and the precision rules mirror Core's `Theme::SettingOptionIcon` and spec validator, so the editor, the platform and theme-check agree on what is valid. theme-check downloads these schemas from this repository and uses them for its ValidSchema check. Assisted-By: devx/0e7ab314-3226-4582-ac8d-b65bca9cbfd8 --- schemas/theme/setting.json | 133 +++++++- tests/setting.spec.ts | 311 +++++++++++++++++++ tests/theme-settings/setting_options.spec.ts | 125 ++++++++ 3 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 tests/setting.spec.ts create mode 100644 tests/theme-settings/setting_options.spec.ts diff --git a/schemas/theme/setting.json b/schemas/theme/setting.json index 224d9c4..01ca8c1 100644 --- a/schemas/theme/setting.json +++ b/schemas/theme/setting.json @@ -692,7 +692,21 @@ "type": "string", "description": "A placeholder value for the input." }, - "default": { "type": "number" }, + "default": { "type": "number", "multipleOf": 0.1 }, + "min": { + "type": "number", + "multipleOf": 0.1, + "description": "The smallest value the setting is expected to take. Supports at most one decimal digit. The stored setting value remains numeric, and the default and any option values must fall within the bounds.", + "markdownDescription": "The smallest value the setting is expected to take. Supports at most one decimal digit.\n\nThe stored setting value remains numeric, and `default` and `options[].value` must fall within the bounds.\n\n---\n\n[Shopify reference](https://shopify.dev/docs/themes/architecture/settings/input-settings#number)" + }, + "max": { + "type": "number", + "multipleOf": 0.1, + "description": "The largest value the setting is expected to take. Supports at most one decimal digit. The stored setting value remains numeric, and the default and any option values must fall within the bounds.", + "markdownDescription": "The largest value the setting is expected to take. Supports at most one decimal digit.\n\nThe stored setting value remains numeric, and `default` and `options[].value` must fall within the bounds.\n\n---\n\n[Shopify reference](https://shopify.dev/docs/themes/architecture/settings/input-settings#number)" + }, + "icon": { "$ref": "#/definitions/settingIcon" }, + "options": { "$ref": "#/definitions/numberOptions" }, "label": true, "info": true, "id": true, @@ -768,7 +782,7 @@ "type": "string", "description": "The value of the default option" }, - "options": { "$ref": "#/definitions/options" }, + "options": { "$ref": "#/definitions/radioOptions" }, "label": true, "info": true, "id": true, @@ -1391,8 +1405,22 @@ } }, + "radioOptions": { + "description": "Takes an array of `value`/`label` definitions for radio fields.", + "type": "array", + "items": { + "allOf": [{ "$ref": "#/definitions/options/items" }], + "properties": { + "icon": { + "not": {}, + "errorMessage": "The property icon is not allowed on radio options. Use a select setting for options with icons." + } + } + } + }, + "selectOptions": { - "description": "Takes an array of `value`/`label`/`group` definitions for select fields.", + "description": "Takes an array of `value`/`label`/`group`/`icon` definitions for select fields.", "type": "array", "items": { "allOf": [ @@ -1403,11 +1431,108 @@ "group": { "type": "string", "description": "An optional attribute that you can add to each option to create option groups in the drop-down." - } + }, + "icon": { "$ref": "#/definitions/settingIcon" } } } ] } + }, + + "numberOptions": { + "description": "Takes an array of `value`/`label`/`icon` definitions that provide preset numeric values for number fields.", + "markdownDescription": "Takes an array of `value`/`label`/`icon` definitions that provide preset numeric values for number fields.\n\nOptions define preset numeric values for the setting; the stored setting value remains numeric. Values are numbers with at most one decimal digit.", + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "The value of the option. Supports at most one decimal digit.", + "type": "number", + "multipleOf": 0.1 + }, + "label": { + "description": "The label of the option.", + "type": "string" + }, + "icon": { "$ref": "#/definitions/settingIcon" } + }, + "required": ["value"], + "additionalProperties": false + } + }, + + "settingIcon": { + "type": "string", + "description": "The icon that represents the setting or option in the theme editor. Must be one of the supported stable icon IDs.", + "markdownDescription": "The icon that represents the setting or option in the theme editor.\n\nMust be one of the supported stable icon IDs, written in `snake_case` (for example `layout_columns_2`). Polaris component names such as `LayoutColumns3Icon` and admin handles such as `layout-columns-3` aren't valid.", + "errorMessage": "Icon must be one of the supported snake_case stable icon IDs, such as \"layout_columns_2\". Polaris component names and kebab-case handles aren't supported.", + "enum": [ + "text_align_left", + "text_align_center", + "text_align_right", + "text_title", + "text_in_columns", + "text_in_rows", + "layout_column_1", + "layout_columns_2", + "layout_columns_3", + "layout_rows_2", + "layout_block", + "layout_section", + "layout_header", + "layout_footer", + "layout_sidebar_left", + "layout_sidebar_right", + "arrow_up", + "arrow_down", + "arrow_left", + "arrow_right", + "dock_side", + "dock_floating", + "desktop", + "tablet", + "mobile", + "viewport_narrow", + "viewport_wide", + "maximize", + "minimize", + "image", + "images", + "slideshow", + "crop", + "rotate_left", + "rotate_right", + "product", + "product_list", + "collection", + "collection_list", + "cart", + "cart_discount", + "discount", + "payment", + "credit_card", + "currency_convert", + "money", + "cash_dollar", + "price_list", + "color", + "color_none", + "text_color", + "email", + "email_newsletter", + "email_follow_up", + "list_bulleted", + "list_numbered", + "search_list", + "sun", + "moon", + "corner_square", + "corner_round", + "corner_pill", + "view", + "hide" + ] } } } diff --git a/tests/setting.spec.ts b/tests/setting.spec.ts new file mode 100644 index 0000000..c16e460 --- /dev/null +++ b/tests/setting.spec.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; +import { validateSchema } from './test-helpers'; + +const validate = validateSchema(); + +const UNSUPPORTED_ICON_MESSAGE = + 'Icon must be one of the supported snake_case stable icon IDs, such as "layout_columns_2". Polaris component names and kebab-case handles aren\'t supported.'; +const RADIO_ICON_MESSAGE = + 'The property icon is not allowed on radio options. Use a select setting for options with icons.'; +const NOT_DIVISIBLE_MESSAGE = 'Value is not divisible by 0.1.'; + +const sectionSchema = (setting: any) => ({ name: 'Test', settings: [setting] }); + +describe('JSON Schema validation of setting option icons and number bounds', () => { + describe('Unit: select options', () => { + it('accepts a select option with a supported icon', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'select', + id: 'layout', + label: 'Layout', + options: [{ value: 'two', label: 'Two', icon: 'layout_columns_2' }], + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('rejects a select option icon using a Polaris component name', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'select', + id: 'layout', + label: 'Layout', + options: [{ value: 'three', label: 'Three', icon: 'LayoutColumns3Icon' }], + }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: UNSUPPORTED_ICON_MESSAGE }), + ]); + }); + + it('rejects a select option icon using a kebab-case admin handle', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'select', + id: 'layout', + label: 'Layout', + options: [{ value: 'three', label: 'Three', icon: 'layout-columns-3' }], + }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: UNSUPPORTED_ICON_MESSAGE }), + ]); + }); + }); + + describe('Unit: radio options', () => { + it('rejects a radio option with an icon', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'radio', + id: 'layout', + label: 'Layout', + options: [{ value: 'two', label: 'Two', icon: 'layout_columns_2' }], + }), + ); + + expect(diagnostics).toStrictEqual([expect.objectContaining({ message: RADIO_ICON_MESSAGE })]); + }); + }); + + describe('Unit: number settings', () => { + it('accepts a number setting with min, max, icon, and options', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'columns', + label: 'Columns', + default: 8, + min: 4, + max: 16, + icon: 'layout_section', + options: [ + { value: 4, label: 'Small', icon: 'layout_column_1' }, + { value: 8, label: 'Medium', icon: 'layout_columns_2' }, + { value: 16, label: 'Large', icon: 'layout_columns_3' }, + ], + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('accepts a number setting with one-decimal values', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + default: 20.5, + min: 0.5, + max: 100.5, + options: [{ value: 20.5, label: 'Default' }], + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('accepts a number setting with values beyond fractional precision', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + default: 1e20, + min: 0, + max: 1e20, + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('rejects a number setting default with more than one decimal digit', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ type: 'number', id: 'width', label: 'Width', default: 20.55 }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: NOT_DIVISIBLE_MESSAGE }), + ]); + }); + + it('rejects a number setting min with more than one decimal digit', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ type: 'number', id: 'width', label: 'Width', min: 0.25 }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: NOT_DIVISIBLE_MESSAGE }), + ]); + }); + + it('rejects a number setting max with more than one decimal digit', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ type: 'number', id: 'width', label: 'Width', max: 100.75 }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: NOT_DIVISIBLE_MESSAGE }), + ]); + }); + + it('rejects a number option value with more than one decimal digit', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + options: [{ value: 0.333, label: 'Third' }], + }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: NOT_DIVISIBLE_MESSAGE }), + ]); + }); + + it('rejects a number option value that is a datasource string', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'price', + label: 'Price', + options: [{ value: 'products.first.price', label: 'First' }], + }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: 'Incorrect type. Expected "number".' }), + ]); + }); + + it('rejects a number option that is missing a value', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + options: [{ label: 'Medium' }], + }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: 'Missing property "value".' }), + ]); + }); + + it('rejects a number option with an unknown attribute', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + options: [{ value: 4, bogus: 1 }], + }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: 'Property bogus is not allowed.' }), + ]); + }); + + it('rejects a number setting icon outside the supported icon list', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ type: 'number', id: 'width', label: 'Width', icon: 'grid' }), + ); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: UNSUPPORTED_ICON_MESSAGE }), + ]); + }); + }); + + describe('Unit: number setting cross-field bounds (enforced by Core, not expressible in JSON Schema)', () => { + it('accepts a default below min', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ type: 'number', id: 'width', label: 'Width', default: 2, min: 4, max: 16 }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('accepts a default above max', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + default: 20, + min: 4, + max: 16, + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('accepts an option value below min', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + min: 4, + max: 16, + options: [{ value: 2, label: 'Tiny' }], + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('accepts an option value above max', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ + type: 'number', + id: 'width', + label: 'Width', + min: 4, + max: 16, + options: [{ value: 20, label: 'Huge' }], + }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + + it('accepts a max below min', async () => { + const diagnostics = await validate( + 'sections/section.liquid', + sectionSchema({ type: 'number', id: 'width', label: 'Width', min: 16, max: 4 }), + ); + + expect(diagnostics).toStrictEqual([]); + }); + }); +}); diff --git a/tests/theme-settings/setting_options.spec.ts b/tests/theme-settings/setting_options.spec.ts new file mode 100644 index 0000000..eae258a --- /dev/null +++ b/tests/theme-settings/setting_options.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { validateSchema } from '../test-helpers'; + +const validate = validateSchema(); + +const UNSUPPORTED_ICON_MESSAGE = + 'Icon must be one of the supported snake_case stable icon IDs, such as "layout_columns_2". Polaris component names and kebab-case handles aren\'t supported.'; +const RADIO_ICON_MESSAGE = + 'The property icon is not allowed on radio options. Use a select setting for options with icons.'; + +describe('Module: theme settings validation (config/settings_schema.json)', () => { + describe('Unit: option icons and number bounds', () => { + it('select setting allows an option icon', async () => { + const settings = `[ + { + "name": "some category", + "settings": [ + { + "type": "select", + "id": "layout", + "label": "Layout", + "options": [{ "value": "two", "label": "Two", "icon": "layout_columns_2" }] + } + ] + } + ]`; + + const diagnostics = await validate('config/settings_schema.json', settings); + + expect(diagnostics).toHaveLength(0); + }); + + it('select setting rejects a kebab-case option icon', async () => { + const settings = `[ + { + "name": "some category", + "settings": [ + { + "type": "select", + "id": "layout", + "label": "Layout", + "options": [{ "value": "three", "label": "Three", "icon": "layout-columns-3" }] + } + ] + } + ]`; + + const diagnostics = await validate('config/settings_schema.json', settings); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: UNSUPPORTED_ICON_MESSAGE }), + ]); + }); + + it('radio setting rejects an option icon', async () => { + const settings = `[ + { + "name": "some category", + "settings": [ + { + "type": "radio", + "id": "layout", + "label": "Layout", + "options": [{ "value": "two", "label": "Two", "icon": "layout_columns_2" }] + } + ] + } + ]`; + + const diagnostics = await validate('config/settings_schema.json', settings); + + expect(diagnostics).toStrictEqual([expect.objectContaining({ message: RADIO_ICON_MESSAGE })]); + }); + + it('number setting allows min, max, icon, and options', async () => { + const settings = `[ + { + "name": "some category", + "settings": [ + { + "type": "number", + "id": "columns", + "label": "Columns", + "default": 8, + "min": 4, + "max": 16, + "icon": "layout_section", + "options": [ + { "value": 4, "label": "Small", "icon": "layout_column_1" }, + { "value": 8, "label": "Medium", "icon": "layout_columns_2" }, + { "value": 16, "label": "Large", "icon": "layout_columns_3" } + ] + } + ] + } + ]`; + + const diagnostics = await validate('config/settings_schema.json', settings); + + expect(diagnostics).toHaveLength(0); + }); + + it('number setting rejects bounds with more than one decimal digit', async () => { + const settings = `[ + { + "name": "some category", + "settings": [ + { + "type": "number", + "id": "width", + "label": "Width", + "min": 0.25 + } + ] + } + ]`; + + const diagnostics = await validate('config/settings_schema.json', settings); + + expect(diagnostics).toStrictEqual([ + expect.objectContaining({ message: 'Value is not divisible by 0.1.' }), + ]); + }); + }); +});