Commit 6cc92654d33 for woocommerce
commit 6cc92654d33fc3db8d7ac2e84e410d56f054d79e
Author: Ahmed <ahmed.el.azzabi@automattic.com>
Date: Mon Sep 7 18:23:08 2026 +0100
Canonicalize Settings UI values before rendering (#67335)
* fix(settings): canonicalize Settings UI values
Legacy and native settings schemas could expose lossy or ambiguous values to
DataForm. Canonicalize supported values at the PHP boundary, preserve safe
original form representations for untouched fields, and keep the classic save
pipeline authoritative. Fail back to classic rendering when the schema cannot
meet that contract.
Refs WOOPRD-3594
* fix(settings): gate Settings UI integer promotion on value and bounds
A legacy 'number' control with step=1 and an integral min (the common
step=1/min=0 pattern) was promoted to 'integer' from its metadata shape
alone, ignoring the stored value. A decimal value then failed integer
canonicalization and the whole settings section fell back to the classic
renderer.
Promote only when the stored value and the min/max bounds are all
integral, so a step=1 field holding a decimal stays a 'number' field and
renders instead of collapsing the section. This also covers the
previously unchecked max bound.
* docs(settings): document from_legacy_settings canonicalization throws
from_legacy_settings() now runs typed canonicalization, so it can throw
InvalidArgumentException for ambiguous checkbox values, out-of-range or
non-finite numbers, malformed datetimes, and unsupported form-post field
names. Its @throws tag only documented duplicate group ids. Document the
new failure modes for external callers of this public method.
* fix(settings): return zero before exponent expansion in canonicalization
get_integral_decimal() normalized a decimal string, then expanded its
exponent by appending zeros. When the digit count plus the appended zeros
passed 17, it substituted a 17-nine overflow sentinel. The only zero check
sat inside the separate guard for exponents longer than six digits, so a
zero with a moderate exponent never reached it.
A numeric setting holding "0e17" therefore canonicalized to
99999999999999999, failed the JavaScript safe integer assertion, and
dropped the whole section to the classic settings fallback, even though the
stored value is zero.
Zero stays zero at every exponent, so return '0' as soon as the normalized
digits are zero, before either the large-exponent guard or the expansion.
The two later zero checks become unreachable and are removed. Regression
fixtures cover "0e17", "-0e17", ".0e30" and the already-guarded
"0e99999999".
Refs WOOPRD-3594
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Kg1wVdkfawLaec5dm9WY
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/packages/js/settings-ui/changelog/fix-wooprd-3594-settings-value-boundary b/packages/js/settings-ui/changelog/fix-wooprd-3594-settings-value-boundary
new file mode 100644
index 00000000000..b827c47c4af
--- /dev/null
+++ b/packages/js/settings-ui/changelog/fix-wooprd-3594-settings-value-boundary
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve legacy form names and typed visibility comparisons in the Settings UI.
diff --git a/packages/js/settings-ui/package.json b/packages/js/settings-ui/package.json
index ff26f5037d2..38895c0b66e 100644
--- a/packages/js/settings-ui/package.json
+++ b/packages/js/settings-ui/package.json
@@ -39,6 +39,7 @@
"@wordpress/admin-ui": "catalog:wp-bundled",
"@wordpress/components": "catalog:wp-min",
"@wordpress/dataviews": "17.1.0",
+ "@wordpress/date": "catalog:wp-min",
"@wordpress/element": "catalog:wp-min",
"@wordpress/i18n": "catalog:wp-min"
},
diff --git a/packages/js/settings-ui/src/dataform-adapter.tsx b/packages/js/settings-ui/src/dataform-adapter.tsx
index ddfe51aefd3..06d691550b4 100644
--- a/packages/js/settings-ui/src/dataform-adapter.tsx
+++ b/packages/js/settings-ui/src/dataform-adapter.tsx
@@ -66,6 +66,7 @@ const settingsTypeDescriptors: Record< string, SettingsTypeDescriptor > = {
text: { type: 'text' },
password: { type: 'password' },
number: { type: 'number' },
+ integer: { type: 'integer' },
checkbox: { type: 'boolean' },
email: { type: 'email' },
url: { type: 'url' },
@@ -176,7 +177,7 @@ const toRangeConstraint = (
return undefined;
}
- if ( type === 'number' ) {
+ if ( type === 'number' || type === 'integer' ) {
const numeric = Number( value );
return Number.isFinite( numeric ) ? numeric : undefined;
}
@@ -209,18 +210,25 @@ const buildValidationRules = (
descriptor: SettingsTypeDescriptor | undefined
): Rules< SettingsValues > => {
const attributes = settingsField.customAttributes ?? {};
+ const validation = settingsField.validation ?? {};
const rules: Rules< SettingsValues > = {};
if ( isAttributeSet( attributes.required ) ) {
rules.required = true;
}
- const min = toRangeConstraint( attributes.min, descriptor?.type );
+ const min = toRangeConstraint(
+ validation.min ?? attributes.min,
+ descriptor?.type
+ );
if ( typeof min !== 'undefined' ) {
rules.min = min;
}
- const max = toRangeConstraint( attributes.max, descriptor?.type );
+ const max = toRangeConstraint(
+ validation.max ?? attributes.max,
+ descriptor?.type
+ );
if ( typeof max !== 'undefined' ) {
rules.max = max;
}
diff --git a/packages/js/settings-ui/src/hidden-inputs.tsx b/packages/js/settings-ui/src/hidden-inputs.tsx
index 3491aac16fa..380a4aa9afe 100644
--- a/packages/js/settings-ui/src/hidden-inputs.tsx
+++ b/packages/js/settings-ui/src/hidden-inputs.tsx
@@ -8,20 +8,102 @@ import { createElement, Fragment } from '@wordpress/element';
*/
import { error } from './diagnostics';
import type { SettingsUIField, SettingsValue } from './types';
+import { areValuesEqual, toStoreLocalDateTime } from './values';
type HiddenInput = {
name: string;
value: string;
};
-const getFieldName = ( field: SettingsUIField ) => field.save?.name || field.id;
+const getFieldName = ( field: SettingsUIField ) => field.save?.name ?? field.id;
const getArrayFieldName = ( name: string ) =>
name.endsWith( '[]' ) ? name : `${ name }[]`;
+const isSupportedFieldName = ( name: string, isArray: boolean ) => {
+ const baseName =
+ isArray && name.endsWith( '[]' ) ? name.slice( 0, -2 ) : name;
+
+ // Accept a flat name or one bracketed segment. Array fields can also use a
+ // trailing []. Keep this in sync with
+ // SettingsUISchema::is_supported_form_post_name().
+ return /^[^\[\]]+(?:\[[^\[\]]+\])?$/.test( baseName );
+};
+
+const toRepeatedInputs = ( name: string, values: string[] ): HiddenInput[] =>
+ values.map( ( item ) => ( {
+ name: getArrayFieldName( name ),
+ value: item,
+ } ) );
+
+const serializeCanonicalValue = (
+ field: SettingsUIField,
+ name: string,
+ value: SettingsValue,
+ serializeDateTimeAsStoreLocal: boolean
+): HiddenInput[] => {
+ if ( field.type === 'checkbox' ) {
+ // Accept the canonical boolean as well as the legacy truthy-string
+ // forms ('yes'/'1') that the exported getHiddenInputs()/HiddenInputs()
+ // API accepted before value canonicalization, so external callers
+ // passing a classic checkbox value do not get it silently flipped off.
+ const isChecked = value === true || value === 'yes' || value === '1';
+ return [ { name, value: isChecked ? 'yes' : 'no' } ];
+ }
+
+ if ( field.type === 'array' ) {
+ return toRepeatedInputs( name, Array.isArray( value ) ? value : [] );
+ }
+
+ let serializedValue = '';
+
+ if ( field.type === 'datetime-local' && serializeDateTimeAsStoreLocal ) {
+ serializedValue = toStoreLocalDateTime( value );
+ } else if ( value !== null && typeof value !== 'undefined' ) {
+ serializedValue = String( value );
+ }
+
+ return [
+ {
+ name,
+ value: serializedValue,
+ },
+ ];
+};
+
+const serializeOriginalFormValue = (
+ name: string,
+ value: string | string[]
+): HiddenInput[] =>
+ Array.isArray( value )
+ ? toRepeatedInputs( name, value )
+ : [ { name, value } ];
+
+const handleUnsupportedField = (
+ message: string,
+ field: SettingsUIField,
+ strict: boolean
+): HiddenInput[] => {
+ if ( strict ) {
+ throw new Error( message );
+ }
+
+ error( message, { field } );
+ return [];
+};
+
export const getHiddenInputs = (
field: SettingsUIField,
- value: SettingsValue
+ value: SettingsValue,
+ {
+ initialCanonicalValue,
+ serializeDateTimeAsStoreLocal = false,
+ strict = false,
+ }: {
+ initialCanonicalValue?: SettingsValue;
+ serializeDateTimeAsStoreLocal?: boolean;
+ strict?: boolean;
+ } = {}
): HiddenInput[] => {
const adapter = field.save?.adapter || 'form_post';
@@ -30,51 +112,75 @@ export const getHiddenInputs = (
}
if ( adapter !== 'form_post' ) {
- error( `Save adapter "${ adapter }" is not supported.`, { field } );
- return [];
+ return handleUnsupportedField(
+ `Save adapter "${ adapter }" is not supported.`,
+ field,
+ strict
+ );
}
const name = getFieldName( field );
- if ( field.type === 'checkbox' ) {
- return [
- {
- name,
- value:
- value === true || value === 'yes' || value === '1'
- ? 'yes'
- : 'no',
- },
- ];
+ if ( strict && ! isSupportedFieldName( name, field.type === 'array' ) ) {
+ return handleUnsupportedField(
+ `Form-post field name "${ name }" is not supported.`,
+ field,
+ strict
+ );
}
- if ( field.type === 'array' ) {
- return ( Array.isArray( value ) ? value : [] ).map( ( item ) => ( {
- name: getArrayFieldName( name ),
- value: String( item ),
- } ) );
+ if (
+ field.save &&
+ Object.prototype.hasOwnProperty.call( field.save, 'initialValue' ) &&
+ Array.isArray( field.save.initialValue ) &&
+ field.type !== 'array'
+ ) {
+ return handleUnsupportedField(
+ `Field "${ field.id }" has a list initialValue but is not an array field.`,
+ field,
+ strict
+ );
}
- return [
- {
+ if (
+ field.save &&
+ Object.prototype.hasOwnProperty.call( field.save, 'initialValue' ) &&
+ typeof initialCanonicalValue !== 'undefined' &&
+ areValuesEqual( value, initialCanonicalValue )
+ ) {
+ return serializeOriginalFormValue(
name,
- value:
- value === null || typeof value === 'undefined'
- ? ''
- : String( value ),
- },
- ];
+ field.save.initialValue as string | string[]
+ );
+ }
+
+ return serializeCanonicalValue(
+ field,
+ name,
+ value,
+ serializeDateTimeAsStoreLocal
+ );
};
export const HiddenInputs = ( {
field,
value,
+ initialCanonicalValue,
+ serializeDateTimeAsStoreLocal = false,
+ strict = false,
}: {
field: SettingsUIField;
value: SettingsValue;
+ initialCanonicalValue?: SettingsValue;
+ serializeDateTimeAsStoreLocal?: boolean;
+ strict?: boolean;
} ) => (
<>
- { getHiddenInputs( field, value ).map( ( input, index ) => (
+ { getHiddenInputs( field, value, {
+ initialCanonicalValue,
+ serializeDateTimeAsStoreLocal,
+ strict,
+ } ).map( ( input, index ) => (
<input
key={ `${ input.name }-${ index }` }
type="hidden"
diff --git a/packages/js/settings-ui/src/registry.ts b/packages/js/settings-ui/src/registry.ts
index c7a4c21c44a..47353a06f48 100644
--- a/packages/js/settings-ui/src/registry.ts
+++ b/packages/js/settings-ui/src/registry.ts
@@ -199,7 +199,13 @@ export const resolveFieldComponent = (
findInMatchingRegistrations(
context,
( registration ) => registration.typeRenderers?.[ field.type ]
- );
+ ) ??
+ ( field.type === 'integer'
+ ? findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.typeRenderers?.number
+ )
+ : undefined );
if ( resolvedComponent ) {
return resolvedComponent;
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index f8e45a3c41e..e89af3296b8 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -693,23 +693,34 @@ export const SettingsUIPage = ( {
() => dataFormAdapter.getForm( values ),
[ dataFormAdapter, values ]
);
+ const allFields = useMemo( () => getAllFields( schema ), [ schema ] );
+ const fieldsById = useMemo(
+ () => new Map( allFields.map( ( field ) => [ field.id, field ] ) ),
+ [ allFields ]
+ );
const handleDataFormChange = useCallback(
( nextValues: Record< string, SettingsValue | undefined > ) => {
const merged: Partial< SettingsValues > = {};
- // Package controls emit undefined for a cleared value; the settings
- // vocabulary represents that as an empty string.
Object.entries( nextValues ).forEach( ( [ fieldId, value ] ) => {
- merged[ fieldId ] = typeof value === 'undefined' ? '' : value;
+ const fieldType = fieldsById.get( fieldId )?.type;
+ const emptyValue =
+ fieldType === 'number' ||
+ fieldType === 'integer' ||
+ fieldType === 'datetime-local'
+ ? null
+ : '';
+ merged[ fieldId ] =
+ typeof value === 'undefined' ? emptyValue : value;
} );
setValues( merged );
},
- [ setValues ]
+ [ fieldsById, setValues ]
);
const formPostFields =
- saveStrategy.adapter === 'form_post' ? getAllFields( schema ) : [];
+ saveStrategy.adapter === 'form_post' ? allFields : [];
const showHeader = schema.shell?.header === 'visible';
const saveButtonLabel = __( 'Save', 'woocommerce' );
@@ -780,6 +791,9 @@ export const SettingsUIPage = ( {
<HiddenInputs
field={ field }
value={ values[ field.id ] }
+ initialCanonicalValue={ initialValues[ field.id ] }
+ serializeDateTimeAsStoreLocal
+ strict
key={ field.id }
/>
) ) }
diff --git a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
index 93e66217a3e..2958415481b 100644
--- a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
+++ b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
@@ -98,6 +98,7 @@ describe( 'dataform adapter', () => {
[ 'radio', 'text' ],
[ 'checkbox', 'boolean' ],
[ 'number', 'number' ],
+ [ 'integer', 'integer' ],
[ 'array', 'array' ],
];
@@ -652,6 +653,34 @@ describe( 'dataform adapter', () => {
expect( field.isValid?.max ).toBe( 100 );
} );
+ it( 'maps integer range attributes to numeric constraints', () => {
+ const field = buildDataFormField(
+ {
+ ...textField,
+ type: 'integer',
+ customAttributes: { min: '0', max: 100 },
+ },
+ createOptions( [] )
+ );
+
+ expect( field.isValid?.min ).toBe( 0 );
+ expect( field.isValid?.max ).toBe( 100 );
+ } );
+
+ it( 'maps canonical numeric validation to range constraints', () => {
+ const field = buildDataFormField(
+ {
+ ...textField,
+ type: 'integer',
+ validation: { min: 1, max: 9 },
+ },
+ createOptions( [] )
+ );
+
+ expect( field.isValid?.min ).toBe( 1 );
+ expect( field.isValid?.max ).toBe( 9 );
+ } );
+
it( 'maps date range attributes as strings', () => {
const field = buildDataFormField(
{
diff --git a/packages/js/settings-ui/src/test/hidden-inputs.test.ts b/packages/js/settings-ui/src/test/hidden-inputs.test.ts
index a2b790f6f22..b037b184606 100644
--- a/packages/js/settings-ui/src/test/hidden-inputs.test.ts
+++ b/packages/js/settings-ui/src/test/hidden-inputs.test.ts
@@ -1,7 +1,60 @@
+/**
+ * External dependencies
+ */
+import { getSettings, setSettings } from '@wordpress/date';
+
/**
* Internal dependencies
*/
import { getHiddenInputs } from '../hidden-inputs';
+import type { SettingsUIField, SettingsValue } from '../types';
+
+const formPostField = (
+ overrides: Partial< SettingsUIField > = {}
+): SettingsUIField => ( {
+ id: 'quantity',
+ label: 'Quantity',
+ type: 'number',
+ save: { adapter: 'form_post', name: 'quantity' },
+ ...overrides,
+} );
+
+const unsupportedFieldCases = [
+ {
+ label: 'save adapter',
+ field: formPostField( {
+ save: { adapter: 'custom', name: 'quantity' },
+ } ),
+ message: 'Save adapter "custom" is not supported.',
+ },
+ {
+ label: 'form-post field name',
+ field: formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'settings[group][quantity',
+ },
+ } ),
+ message:
+ 'Form-post field name "settings[group][quantity" is not supported.',
+ },
+ {
+ label: 'list initialValue for a scalar field',
+ field: formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'quantity',
+ initialValue: [ '1', '2' ],
+ },
+ } ),
+ message:
+ 'Field "quantity" has a list initialValue but is not an array field.',
+ },
+];
+
+const unsupportedDefaultCases = unsupportedFieldCases.filter(
+ ( { label } ) => label !== 'form-post field name'
+);
describe( 'getHiddenInputs', () => {
it( 'serializes checkbox values for legacy form posts', () => {
@@ -18,6 +71,42 @@ describe( 'getHiddenInputs', () => {
).toEqual( [ { name: 'enabled', value: 'yes' } ] );
} );
+ it.each( [ 'yes', '1' ] )(
+ 'treats the legacy truthy-string checkbox value %p as checked',
+ ( legacyValue ) => {
+ expect(
+ getHiddenInputs(
+ {
+ id: 'enabled',
+ label: 'Enabled',
+ type: 'checkbox',
+ save: { adapter: 'form_post', name: 'enabled' },
+ },
+ legacyValue
+ )
+ ).toEqual( [ { name: 'enabled', value: 'yes' } ] );
+ }
+ );
+
+ it( 'serializes changed checkbox values instead of their original form representation', () => {
+ expect(
+ getHiddenInputs(
+ {
+ id: 'enabled',
+ label: 'Enabled',
+ type: 'checkbox',
+ save: {
+ adapter: 'form_post',
+ name: 'enabled',
+ initialValue: 'yes',
+ },
+ },
+ false,
+ { initialCanonicalValue: true }
+ )
+ ).toEqual( [ { name: 'enabled', value: 'no' } ] );
+ } );
+
it( 'serializes array values with bracketed field names', () => {
expect(
getHiddenInputs(
@@ -35,6 +124,25 @@ describe( 'getHiddenInputs', () => {
] );
} );
+ it( 'omits an unchanged empty array like the classic form', () => {
+ expect(
+ getHiddenInputs(
+ {
+ id: 'methods',
+ label: 'Methods',
+ type: 'array',
+ save: {
+ adapter: 'form_post',
+ name: 'methods',
+ initialValue: [],
+ },
+ },
+ [],
+ { initialCanonicalValue: [] }
+ )
+ ).toEqual( [] );
+ } );
+
it( 'does not serialize fields using the none adapter', () => {
expect(
getHiddenInputs(
@@ -48,4 +156,349 @@ describe( 'getHiddenInputs', () => {
)
).toEqual( [] );
} );
+
+ it( 'serializes the current value in a two-argument call even when an original form value exists', () => {
+ const field = formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'quantity',
+ initialValue: '02',
+ },
+ } );
+
+ expect( getHiddenInputs( field, 2 ) ).toEqual( [
+ { name: 'quantity', value: '2' },
+ ] );
+ } );
+
+ it.each( unsupportedDefaultCases )(
+ 'handles an unsupported $label gracefully by default',
+ ( { field, message } ) => {
+ const consoleError = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => undefined );
+
+ expect(
+ getHiddenInputs( field, 2, { initialCanonicalValue: 1 } )
+ ).toEqual( [] );
+ expect( consoleError ).toHaveBeenCalledWith(
+ `[WooCommerce settings UI] ${ message }`,
+ { field }
+ );
+
+ consoleError.mockRestore();
+ }
+ );
+
+ it( 'keeps legacy field names serializable through the public default API', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ save: { adapter: 'form_post', name: 'settings[]' },
+ } ),
+ 2
+ )
+ ).toEqual( [ { name: 'settings[]', value: '2' } ] );
+ } );
+
+ it( 'preserves a zero form-post field name', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ save: { adapter: 'form_post', name: '0' },
+ } ),
+ 2,
+ { strict: true }
+ )
+ ).toEqual( [ { name: '0', value: '2' } ] );
+ } );
+
+ it.each( unsupportedFieldCases )(
+ 'throws for an unsupported $label in strict mode',
+ ( { field, message } ) => {
+ expect( () =>
+ getHiddenInputs( field, 2, {
+ initialCanonicalValue: 1,
+ strict: true,
+ } )
+ ).toThrow( message );
+ }
+ );
+
+ it( 'preserves the original form representation while the canonical value is unchanged', () => {
+ const field = formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'quantity',
+ initialValue: '02',
+ },
+ } );
+
+ expect(
+ getHiddenInputs( field, 2, { initialCanonicalValue: 2 } )
+ ).toEqual( [ { name: 'quantity', value: '02' } ] );
+ } );
+
+ it( 'serializes an edited canonical value', () => {
+ const field = formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'quantity',
+ initialValue: '02',
+ },
+ } );
+
+ expect(
+ getHiddenInputs( field, 3, { initialCanonicalValue: 2 } )
+ ).toEqual( [ { name: 'quantity', value: '3' } ] );
+ } );
+
+ it( 'serializes a cleared canonical number as an empty string', () => {
+ const field = formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'quantity',
+ initialValue: '02',
+ },
+ } );
+
+ expect(
+ getHiddenInputs( field, null, { initialCanonicalValue: 2 } )
+ ).toEqual( [ { name: 'quantity', value: '' } ] );
+ } );
+
+ it.each< [ SettingsValue, SettingsValue, boolean ] >( [
+ [ 0, 0, true ],
+ [ 0, '0', false ],
+ [ '', '', true ],
+ [ '', false, false ],
+ [ false, false, true ],
+ [ false, null, false ],
+ [ null, null, true ],
+ [ null, '', false ],
+ [ [], [], true ],
+ [ [], [ '' ], false ],
+ ] )(
+ 'distinguishes exact falsey values (%p and %p)',
+ ( currentValue, initialValue, isUnchanged ) => {
+ const field = formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'quantity',
+ initialValue: 'original',
+ },
+ } );
+ const [ input ] = getHiddenInputs( field, currentValue, {
+ initialCanonicalValue: initialValue,
+ } );
+
+ expect( input?.value === 'original' ).toBe( isUnchanged );
+ }
+ );
+
+ it( 'serializes one-level nested names', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'settings[quantity]',
+ },
+ } ),
+ 2,
+ { initialCanonicalValue: 1 }
+ )
+ ).toEqual( [ { name: 'settings[quantity]', value: '2' } ] );
+ } );
+
+ it( 'serializes deep nested names for backward compatibility', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'settings[group][quantity]',
+ },
+ } ),
+ 2,
+ { initialCanonicalValue: 1 }
+ )
+ ).toEqual( [ { name: 'settings[group][quantity]', value: '2' } ] );
+ } );
+
+ it( 'rejects deep nested names in strict schema mode', () => {
+ expect( () =>
+ getHiddenInputs(
+ formPostField( {
+ save: {
+ adapter: 'form_post',
+ name: 'settings[group][quantity]',
+ },
+ } ),
+ 2,
+ { strict: true }
+ )
+ ).toThrow(
+ 'Form-post field name "settings[group][quantity]" is not supported.'
+ );
+ } );
+
+ it( 'preserves original array entries with bracketed one-level nested names', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'array',
+ save: {
+ adapter: 'form_post',
+ name: 'settings[methods]',
+ initialValue: [ 'card', 'link' ],
+ },
+ } ),
+ [ 'card', 'link' ],
+ { initialCanonicalValue: [ 'card', 'link' ] }
+ )
+ ).toEqual( [
+ { name: 'settings[methods][]', value: 'card' },
+ { name: 'settings[methods][]', value: 'link' },
+ ] );
+ } );
+
+ it( 'serializes current array entries with existing bracketed one-level nested names', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'array',
+ save: {
+ adapter: 'form_post',
+ name: 'settings[methods][]',
+ },
+ } ),
+ [ 'card', 'link' ]
+ )
+ ).toEqual( [
+ { name: 'settings[methods][]', value: 'card' },
+ { name: 'settings[methods][]', value: 'link' },
+ ] );
+ } );
+
+ it( 'serializes array entries with existing deep nested names', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'array',
+ save: {
+ adapter: 'form_post',
+ name: 'settings[group][methods][]',
+ },
+ } ),
+ [ 'card', 'link' ]
+ )
+ ).toEqual( [
+ { name: 'settings[group][methods][]', value: 'card' },
+ { name: 'settings[group][methods][]', value: 'link' },
+ ] );
+ } );
+
+ it( 'keeps disabled fields in the form-post entry list', () => {
+ expect(
+ getHiddenInputs( formPostField( { disabled: true } ), 2, {
+ initialCanonicalValue: 1,
+ } )
+ ).toEqual( [ { name: 'quantity', value: '2' } ] );
+ } );
+
+ it( 'keeps hidden fields in the form-post entry list', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ visibility: { controller: 'enabled', value: true },
+ } ),
+ 2,
+ { initialCanonicalValue: 1 }
+ )
+ ).toEqual( [ { name: 'quantity', value: '2' } ] );
+ } );
+
+ it( 'preserves the original form representation for an unchanged canonical datetime', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'datetime-local',
+ save: {
+ adapter: 'form_post',
+ name: 'starts_at',
+ initialValue: '2026-01-01T12:00',
+ },
+ } ),
+ '2026-01-01T12:00:00Z',
+ { initialCanonicalValue: '2026-01-01T12:00:00Z' }
+ )
+ ).toEqual( [ { name: 'starts_at', value: '2026-01-01T12:00' } ] );
+ } );
+
+ it( 'serializes an edited canonical datetime back to store-local form', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'datetime-local',
+ save: {
+ adapter: 'form_post',
+ name: 'starts_at',
+ initialValue: '2026-01-01T12:00',
+ },
+ } ),
+ '2026-01-01T13:30:00Z',
+ {
+ initialCanonicalValue: '2026-01-01T12:00:00Z',
+ serializeDateTimeAsStoreLocal: true,
+ }
+ )
+ ).toEqual( [ { name: 'starts_at', value: '2026-01-01T13:30:00' } ] );
+ } );
+
+ it( 'keeps datetime serialization unchanged for public two-argument calls', () => {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'datetime-local',
+ save: { adapter: 'form_post', name: 'starts_at' },
+ } ),
+ '2026-01-01T17:30:00Z'
+ )
+ ).toEqual( [ { name: 'starts_at', value: '2026-01-01T17:30:00Z' } ] );
+ } );
+
+ it( 'serializes an edited datetime in a non-UTC store timezone', () => {
+ const previousSettings = getSettings();
+ setSettings( {
+ ...previousSettings,
+ timezone: {
+ ...previousSettings.timezone,
+ offset: '-5',
+ offsetFormatted: '-05:00',
+ string: 'America/New_York',
+ abbr: 'EST',
+ },
+ } );
+
+ try {
+ expect(
+ getHiddenInputs(
+ formPostField( {
+ type: 'datetime-local',
+ save: {
+ adapter: 'form_post',
+ name: 'starts_at',
+ },
+ } ),
+ '2026-01-01T17:30:00Z',
+ { serializeDateTimeAsStoreLocal: true }
+ )
+ ).toEqual( [
+ { name: 'starts_at', value: '2026-01-01T12:30:00' },
+ ] );
+ } finally {
+ setSettings( previousSettings );
+ }
+ } );
} );
diff --git a/packages/js/settings-ui/src/test/html-rendering.test.tsx b/packages/js/settings-ui/src/test/html-rendering.test.tsx
index f9fce857f44..e168b6b111a 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -956,6 +956,62 @@ describe( 'settings HTML rendering', () => {
}
} );
+ it.each( [ 'number', 'integer', 'datetime-local' ] )(
+ 'keeps a cleared %s field canonical as null',
+ ( fieldType ) => {
+ registerSettingsExtension( {
+ scope: { page: 'test-page' },
+ components: {
+ 'test/clear-field': ( { data, field, onChange } ) => (
+ <button
+ type="button"
+ onClick={ () =>
+ onChange( { [ field.id ]: undefined } )
+ }
+ >
+ { data[ field.id ] === null
+ ? 'Canonical null'
+ : 'Clear value' }
+ </button>
+ ),
+ },
+ } );
+
+ const schema = createSingleFieldSchema(
+ {
+ id: 'test_field',
+ label: 'Test field',
+ type: fieldType,
+ value:
+ fieldType === 'datetime-local'
+ ? '2026-01-01T12:00:00Z'
+ : 1,
+ component: 'test/clear-field',
+ },
+ { save: { adapter: 'form_post' } }
+ );
+ const { container, form, root } = renderElementInMainForm(
+ <SettingsUIPage schema={ schema } />
+ );
+
+ try {
+ act( () => container.querySelector( 'button' )?.click() );
+
+ expect( container.textContent ).toContain( 'Canonical null' );
+ expect(
+ form
+ .querySelector(
+ 'input[type="hidden"][name="test_field"]'
+ )
+ ?.getAttribute( 'value' )
+ ).toBe( '' );
+ } finally {
+ act( () => root.unmount() );
+ form.remove();
+ }
+ }
+ );
+
it( 'serializes edits from built-in controls into the form-post hidden inputs', () => {
const schema: SettingsUISchema = {
id: 'test-page',
@@ -1040,6 +1096,9 @@ describe( 'settings HTML rendering', () => {
expect( hiddenValues( 'unit' ) ).toEqual( [ 'lbs' ] );
expect( hiddenValues( 'amount' ) ).toEqual( [ '5' ] );
expect( hiddenValues( 'countries[]' ) ).toEqual( [ 'FR', 'ES' ] );
+
+ act( () => changeTextInput( number, '' ) );
+ expect( hiddenValues( 'amount' ) ).toEqual( [ '' ] );
} finally {
act( () => root.unmount() );
form.remove();
diff --git a/packages/js/settings-ui/src/test/registry.test.ts b/packages/js/settings-ui/src/test/registry.test.ts
index 28fc8e6b3d3..69986eb836b 100644
--- a/packages/js/settings-ui/src/test/registry.test.ts
+++ b/packages/js/settings-ui/src/test/registry.test.ts
@@ -91,6 +91,39 @@ describe( 'settings extension registry', () => {
).toBe( fieldOverride );
} );
+ it( 'falls back to number type renderers for promoted integer fields', () => {
+ const numberRenderer: SettingsEditControl = () => null;
+ const integerRenderer: SettingsEditControl = () => null;
+ const field = {
+ id: 'legacy_number',
+ label: 'Legacy number',
+ type: 'integer',
+ };
+ const context = { page: 'registry-integer-fallback' };
+
+ registerSettingsExtension( {
+ scope: context,
+ typeRenderers: {
+ number: numberRenderer,
+ },
+ } );
+
+ expect( resolveFieldComponent( field, context ) ).toBe(
+ numberRenderer
+ );
+
+ registerSettingsExtension( {
+ scope: context,
+ typeRenderers: {
+ integer: integerRenderer,
+ },
+ } );
+
+ expect( resolveFieldComponent( field, context ) ).toBe(
+ integerRenderer
+ );
+ } );
+
it( 'ignores malformed registration payloads', () => {
const warnSpy = jest
.spyOn( console, 'warn' )
diff --git a/packages/js/settings-ui/src/test/values.test.ts b/packages/js/settings-ui/src/test/values.test.ts
new file mode 100644
index 00000000000..0d814ab8e47
--- /dev/null
+++ b/packages/js/settings-ui/src/test/values.test.ts
@@ -0,0 +1,13 @@
+import { valueMatchesVisibilityRule } from '../values';
+
+describe( 'valueMatchesVisibilityRule', () => {
+ it( 'uses true only when the rule value is absent', () => {
+ expect( valueMatchesVisibilityRule( true, undefined ) ).toBe( true );
+ expect( valueMatchesVisibilityRule( false, undefined ) ).toBe( false );
+ } );
+
+ it( 'matches an explicit null rule value', () => {
+ expect( valueMatchesVisibilityRule( null, null ) ).toBe( true );
+ expect( valueMatchesVisibilityRule( true, null ) ).toBe( false );
+ } );
+} );
diff --git a/packages/js/settings-ui/src/types.ts b/packages/js/settings-ui/src/types.ts
index 726dd1ab1e3..9dffb815dd4 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -15,6 +15,7 @@ export type SettingsUISaveAdapter =
export type SettingsUISaveSchema = {
adapter: SettingsUISaveAdapter;
name?: string;
+ initialValue?: string | string[];
};
export type SettingsUISaveStrategy =
@@ -39,6 +40,10 @@ export type SettingsUIField = {
placeholder?: string;
disabled?: boolean;
customAttributes?: Record< string, string | number | boolean >;
+ validation?: {
+ min?: number;
+ max?: number;
+ };
visibility?: SettingsUIVisibilityRule;
save?: SettingsUISaveSchema;
};
diff --git a/packages/js/settings-ui/src/values.ts b/packages/js/settings-ui/src/values.ts
index 91180490af5..2d6ba445eb0 100644
--- a/packages/js/settings-ui/src/values.ts
+++ b/packages/js/settings-ui/src/values.ts
@@ -1,8 +1,15 @@
+/**
+ * External dependencies
+ */
+import { date } from '@wordpress/date';
+
/**
* Internal dependencies
*/
import type { SettingsValue } from './types';
+const STORE_LOCAL_DATETIME_FORMAT = 'Y-m-d\\TH:i:s';
+
export const areValuesEqual = ( a: SettingsValue, b: SettingsValue ) => {
if ( Array.isArray( a ) || Array.isArray( b ) ) {
return (
@@ -22,9 +29,17 @@ export const valueMatchesVisibilityRule = (
) => {
const expectedValues = Array.isArray( expected )
? expected
- : [ expected ?? true ];
+ : [ expected === undefined ? true : expected ];
return expectedValues.some( ( expectedValue ) =>
areValuesEqual( value, expectedValue )
);
};
+
+export const toStoreLocalDateTime = ( value: SettingsValue ) => {
+ if ( typeof value !== 'string' || value === '' ) {
+ return '';
+ }
+
+ return date( STORE_LOCAL_DATETIME_FORMAT, value );
+};
diff --git a/plugins/woocommerce/changelog/fix-wooprd-3594-settings-value-boundary b/plugins/woocommerce/changelog/fix-wooprd-3594-settings-value-boundary
new file mode 100644
index 00000000000..d399e9d160b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooprd-3594-settings-value-boundary
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve classic setting names, values, and visibility rules while canonicalizing typed Settings UI fields.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
index 3c986a0c423..071f1a839ea 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
@@ -691,7 +691,8 @@ class SettingsUIRequestContext {
try {
$schema = $this->settings_ui_page->get_schema( $this->section );
- $schema = SettingsUISchema::canonicalize_option_values( $schema );
+ $schema = SettingsUISchema::canonicalize_schema_values( $schema );
+
$schema = $this->apply_section_navigation( $schema );
$schema = $this->apply_shell_header_visibility( $schema );
$schema = $this->ensure_drill_down_breadcrumbs( $schema );
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
index 2fae9a99db3..d96ca978409 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
@@ -25,6 +25,88 @@ class SettingsUISchema {
*/
private const DEFAULT_GROUP_ID = 'default';
+ /**
+ * Field types that the current Settings UI renderer handles explicitly.
+ *
+ * @var string[]
+ */
+ private const SUPPORTED_FIELD_TYPES = array(
+ 'array',
+ 'checkbox',
+ 'date',
+ 'datetime-local',
+ 'email',
+ 'info',
+ 'integer',
+ 'number',
+ 'password',
+ 'radio',
+ 'select',
+ 'tel',
+ 'text',
+ 'textarea',
+ 'time',
+ 'url',
+ );
+
+ /**
+ * Field types whose values cross the typed canonicalization boundary.
+ *
+ * @var string[]
+ */
+ private const TYPED_VALUE_FIELD_TYPES = array( 'array', 'checkbox', 'datetime-local', 'integer', 'number' );
+
+ /**
+ * Custom attributes that describe an input range.
+ *
+ * @var string[]
+ */
+ private const RANGE_ATTRIBUTES = array( 'min', 'max', 'step' );
+
+ /**
+ * Native temporal field types that accept range attributes.
+ *
+ * @var string[]
+ */
+ private const TEMPORAL_RANGE_FIELD_TYPES = array( 'date', 'datetime-local', 'time' );
+
+ /**
+ * Largest integer JavaScript can represent exactly.
+ *
+ * @var string
+ */
+ private const JAVASCRIPT_SAFE_INTEGER = '9007199254740991';
+
+ /**
+ * HTML decimal-number grammar with named captures for exact normalization.
+ *
+ * Captures the optional sign, whole-number digits, fractional digits after a
+ * whole number, fractional digits without a whole number, and the optional
+ * signed exponent.
+ *
+ * @var string
+ */
+ private const DECIMAL_PATTERN = '/^(?<sign>[+-]?)(?:(?<whole>\d+)(?:\.(?<fraction>\d*))?|\.(?<bare_fraction>\d+))(?:[eE](?<exponent>[+-]?\d+))?$/';
+
+ /**
+ * Store-local datetime grammar with a four-digit year, two-digit date and
+ * time components, and optional seconds. Calendar validity is checked after
+ * the pattern matches.
+ *
+ * @var string
+ */
+ private const LOCAL_DATETIME_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/';
+
+ /**
+ * Timezone-qualified datetime grammar. It uses the local datetime components
+ * and requires either UTC "Z" or a signed two-digit hour and minute offset.
+ * Malformed offsets do not match, and calendar validity is checked after the
+ * pattern matches.
+ *
+ * @var string
+ */
+ private const QUALIFIED_DATETIME_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/';
+
/**
* Build a schema from a legacy WC settings array.
*
@@ -36,7 +118,10 @@ class SettingsUISchema {
* @param array $settings Legacy settings definitions.
* @param string $default_save_adapter Default save adapter.
* @return array
- * @throws \InvalidArgumentException When legacy settings contain duplicate group ids.
+ * @throws \InvalidArgumentException When legacy settings contain duplicate group ids, or when a
+ * legacy value fails canonicalization: an ambiguous checkbox
+ * value, an out-of-range or non-finite number, a malformed
+ * datetime, or an unsupported form-post field name.
*/
public static function from_legacy_settings( string $page_id, string $section, string $title, array $settings, string $default_save_adapter = 'form_post' ): array {
$groups = array();
@@ -125,7 +210,7 @@ class SettingsUISchema {
$decoded_title = html_entity_decode( $title, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
- return array(
+ $schema = array(
'id' => $page_id,
'title' => $decoded_title,
'section' => '' === $section ? self::DEFAULT_GROUP_ID : $section,
@@ -137,6 +222,8 @@ class SettingsUISchema {
),
'groups' => $groups,
);
+
+ return self::canonicalize_schema_values_for_source( $schema, true );
}
// Exception messages are not HTML output. Dynamic values are sanitized once
@@ -191,69 +278,920 @@ class SettingsUISchema {
}
}
- $field_ids = array();
- $visibility_rules = array();
- foreach ( $schema['groups'] as $group ) {
- $group_id = $group['id'];
- self::assert_optional_strings( $group, array( 'title', 'description' ), sprintf( 'Group "%s"', $group_id ) );
+ $field_ids = array();
+ $visibility_rules = array();
+ foreach ( $schema['groups'] as $group ) {
+ $group_id = $group['id'];
+ self::assert_optional_strings( $group, array( 'title', 'description' ), sprintf( 'Group "%s"', $group_id ) );
+
+ if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) || ! ArrayUtil::array_is_list( $group['fields'] ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" fields must be a list.', $group_id ) );
+ }
+
+ foreach ( $group['fields'] as $field_index => $field ) {
+ if ( ! is_array( $field ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" field %d must be an array.', $group_id, $field_index ) );
+ }
+
+ self::assert_non_empty_string( $field['id'] ?? null, sprintf( 'Group "%s" field %d id must be a non-empty string.', $group_id, $field_index ) );
+ $field_id = $field['id'];
+ if ( isset( $field_ids[ $field_id ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field id "%s" is duplicated.', $field_id ) );
+ }
+ if ( isset( $schema['groups'][ $field_id ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field id "%s" collides with a group id.', $field_id ) );
+ }
+
+ $field_ids[ $field_id ] = true;
+ self::assert_field( $field );
+ if ( isset( $field['visibility'] ) ) {
+ $visibility_rules[ $field_id ] = $field['visibility'];
+ }
+ }
+ }
+
+ foreach ( $visibility_rules as $field_id => $visibility ) {
+ $controller = $visibility['controller'];
+ if ( ! isset( $field_ids[ $controller ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" visibility controller "%s" does not reference a field.', $field_id, $controller ) );
+ }
+ }
+ }
+
+ /**
+ * Canonicalize option values supplied by native Settings UI schema providers.
+ *
+ * Schemas built from legacy settings always carry string option values, but
+ * native providers can supply any scalar. The client matches options against
+ * the stored value with strict string comparison, so scalar option values,
+ * the selected values they match, and visibility values compared against
+ * them are cast here to the string the client's own String() coercion
+ * produces. Malformed entries remain unchanged for the provider to fix.
+ *
+ * @since 11.0.0
+ *
+ * @param array $schema Settings UI schema.
+ * @return array Schema with scalar option values canonicalized to strings.
+ */
+ public static function canonicalize_option_values( array $schema ): array {
+ $converted_fields = array();
+ $schema = self::canonicalize_option_values_and_collect( $schema, $converted_fields );
+
+ self::emit_conversion_notice(
+ __METHOD__,
+ $converted_fields,
+ /* translators: %s: comma-separated field ids. */
+ __( 'A Settings UI schema provider supplied non-string option, field, or visibility values that WooCommerce converted for compatibility: %s. Update the provider to supply string values.', 'woocommerce' ),
+ '11.0.0'
+ );
+
+ return $schema;
+ }
+
+ /**
+ * Canonicalize option values and collect affected field ids.
+ *
+ * @param array $schema Settings UI schema.
+ * @param string[] $converted_fields Affected field ids.
+ * @param-out string[] $converted_fields
+ * @return array Canonicalized schema.
+ */
+ private static function canonicalize_option_values_and_collect( array $schema, array &$converted_fields ): array {
+ if ( ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
+ return $schema;
+ }
+
+ $option_field_ids = array();
+
+ foreach ( $schema['groups'] as &$group ) {
+ if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
+ continue;
+ }
+
+ foreach ( $group['fields'] as &$field ) {
+ if (
+ ! is_array( $field ) ||
+ ! isset( $field['id'], $field['options'] ) ||
+ ! is_string( $field['id'] ) ||
+ ! is_array( $field['options'] )
+ ) {
+ continue;
+ }
+
+ $option_field_ids[ $field['id'] ] = true;
+
+ $converted = false;
+
+ foreach ( $field['options'] as &$option ) {
+ if (
+ ! is_array( $option ) ||
+ ! array_key_exists( 'value', $option ) ||
+ is_string( $option['value'] ) ||
+ ! is_scalar( $option['value'] )
+ ) {
+ continue;
+ }
+
+ $option['value'] = self::to_canonical_string( $option['value'] );
+ $converted = true;
+ }
+ unset( $option );
+
+ if ( array_key_exists( 'value', $field ) ) {
+ if ( is_scalar( $field['value'] ) && ! is_string( $field['value'] ) ) {
+ $field['value'] = self::to_canonical_string( $field['value'] );
+ $converted = true;
+ } elseif ( is_array( $field['value'] ) ) {
+ $canonical_list = self::canonicalize_scalar_list( $field['value'] );
+ if ( null !== $canonical_list ) {
+ $field['value'] = $canonical_list;
+ $converted = true;
+ }
+ }
+ }
+
+ if ( $converted ) {
+ $converted_fields[] = $field['id'];
+ }
+ }
+ unset( $field );
+ }
+ unset( $group );
+
+ foreach ( $schema['groups'] as &$group ) {
+ if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
+ continue;
+ }
+
+ foreach ( $group['fields'] as &$field ) {
+ if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_string( $field['id'] ) ) {
+ continue;
+ }
+
+ if ( ! self::is_canonicalizable_visibility_rule( $field['visibility'] ?? null, $option_field_ids ) ) {
+ continue;
+ }
+
+ $rule_value = $field['visibility']['value'];
+
+ if ( is_scalar( $rule_value ) && ! is_string( $rule_value ) ) {
+ $field['visibility']['value'] = self::to_canonical_string( $rule_value );
+ $converted_fields[] = $field['id'];
+ } elseif ( is_array( $rule_value ) ) {
+ $canonical_list = self::canonicalize_scalar_list( $rule_value );
+ if ( null !== $canonical_list ) {
+ $field['visibility']['value'] = $canonical_list;
+ $converted_fields[] = $field['id'];
+ }
+ }
+ }
+ unset( $field );
+ }
+ unset( $group );
+
+ return $schema;
+ }
+
+ /**
+ * Canonicalize typed field values and compatibility metadata.
+ *
+ * This additive entry point preserves canonicalize_option_values() for
+ * existing callers while letting request resolution issue one aggregate
+ * compatibility notice for the complete native-provider pass.
+ *
+ * @since 11.2.0
+ *
+ * @param array $schema Settings UI schema.
+ * @return array Canonicalized schema.
+ * @throws \InvalidArgumentException When a value cannot be converted without loss.
+ */
+ public static function canonicalize_schema_values( array $schema ): array {
+ return self::canonicalize_schema_values_for_source( $schema, false );
+ }
+
+ /**
+ * Canonicalize typed field values for a schema source.
+ *
+ * @param array $schema Settings UI schema.
+ * @param bool $legacy_derived Whether the schema came from legacy settings definitions.
+ * @return array Canonicalized schema.
+ * @throws \InvalidArgumentException When a value cannot be converted without loss.
+ */
+ private static function canonicalize_schema_values_for_source( array $schema, bool $legacy_derived ): array {
+ if ( ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
+ return $schema;
+ }
+
+ $converted_fields = array();
+ $fields_requiring_form_preservation = array();
+ $original_values = array();
+ $controller_types = array();
+
+ foreach ( $schema['groups'] as &$group ) {
+ if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
+ continue;
+ }
+
+ foreach ( $group['fields'] as &$field ) {
+ if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_string( $field['id'] ) ) {
+ continue;
+ }
+
+ if ( array_key_exists( 'value', $field ) ) {
+ $original_values[ $field['id'] ] = $field['value'];
+ }
+ }
+ unset( $field );
+ }
+ unset( $group );
+
+ $schema = self::canonicalize_option_values_and_collect( $schema, $converted_fields );
+ foreach ( $schema['groups'] as &$group ) {
+ if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
+ continue;
+ }
+
+ foreach ( $group['fields'] as &$field ) {
+ if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_string( $field['id'] ) ) {
+ continue;
+ }
+
+ $typed_conversion = self::canonicalize_field( $field, $legacy_derived );
+ if ( $typed_conversion ) {
+ $converted_fields[] = $field['id'];
+ }
+ if ( isset( $field['type'] ) && is_string( $field['type'] ) ) {
+ $controller_types[ $field['id'] ] = $field['type'];
+ }
+
+ $original_value_changed = array_key_exists( $field['id'], $original_values )
+ && array_key_exists( 'value', $field )
+ && $original_values[ $field['id'] ] !== $field['value'];
+ if (
+ $typed_conversion ||
+ (
+ $original_value_changed &&
+ is_string( $field['type'] ?? null ) &&
+ in_array( $field['type'], self::TYPED_VALUE_FIELD_TYPES, true )
+ )
+ ) {
+ $fields_requiring_form_preservation[ $field['id'] ] = true;
+ }
+ }
+ unset( $field );
+ }
+ unset( $group );
+
+ self::canonicalize_typed_visibility_values( $schema, $controller_types, $converted_fields );
+ self::preserve_converted_form_values( $schema, $original_values, $fields_requiring_form_preservation );
+
+ if ( ! $legacy_derived ) {
+ self::emit_conversion_notice(
+ self::class . '::canonicalize_schema_values',
+ $converted_fields,
+ /* translators: %s: comma-separated field ids. */
+ __( 'A Settings UI schema provider supplied legacy field values or metadata that WooCommerce converted for compatibility: %s. Update the provider to supply canonical values.', 'woocommerce' ),
+ '11.2.0'
+ );
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Canonicalize one field in place.
+ *
+ * @param array $field Field definition.
+ * @param bool $legacy_derived Whether the schema came from legacy settings definitions.
+ * @return bool Whether the field required compatibility conversion.
+ */
+ private static function canonicalize_field( array &$field, bool $legacy_derived ): bool {
+ $type = $field['type'] ?? null;
+ if ( ! is_string( $type ) ) {
+ return false;
+ }
+
+ $original_type = $type;
+ $original_value = $field['value'] ?? null;
+
+ $numeric_validation_converted = false;
+
+ if ( $legacy_derived && 'number' === $type && self::should_promote_to_integer( $field ) ) {
+ $type = 'integer';
+ $field['type'] = $type;
+ }
+
+ if ( array_key_exists( 'value', $field ) ) {
+ switch ( $type ) {
+ case 'array':
+ $field['value'] = self::canonicalize_array_value( $field['value'], $field['id'] );
+ break;
+ case 'checkbox':
+ $field['value'] = self::canonicalize_checkbox_value( $field['value'], $field['id'] );
+ break;
+ case 'number':
+ $field['value'] = self::canonicalize_number( $field['value'], false, $field['id'], 'value' );
+ break;
+ case 'integer':
+ $field['value'] = self::canonicalize_number( $field['value'], true, $field['id'], 'value' );
+ break;
+ case 'datetime-local':
+ $field['value'] = self::canonicalize_datetime( $field['value'], $field['id'] );
+ break;
+ default:
+ if ( in_array( $type, self::SUPPORTED_FIELD_TYPES, true ) && ( null === $field['value'] || is_scalar( $field['value'] ) ) && ! is_string( $field['value'] ) ) {
+ $field['value'] = null === $field['value'] ? '' : self::to_canonical_string( $field['value'] );
+ }
+ break;
+ }
+ }
+
+ if ( in_array( $type, array( 'number', 'integer' ), true ) ) {
+ $numeric_validation_converted = self::canonicalize_numeric_validation( $field, 'integer' === $type );
+ }
+
+ return (
+ $original_type !== $field['type'] ||
+ ( array_key_exists( 'value', $field ) && $original_value !== $field['value'] ) ||
+ $numeric_validation_converted
+ );
+ }
+
+ /**
+ * Canonicalize visibility values with their controller field type.
+ *
+ * @param array $schema Schema being canonicalized.
+ * @param array<string,string> $controller_types Field types keyed by field id.
+ * @param string[] $converted_fields Affected field ids.
+ * @param-out string[] $converted_fields
+ */
+ private static function canonicalize_typed_visibility_values( array &$schema, array $controller_types, array &$converted_fields ): void {
+ foreach ( $schema['groups'] as &$group ) {
+ if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
+ continue;
+ }
+
+ foreach ( $group['fields'] as &$field ) {
+ if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_string( $field['id'] ) ) {
+ continue;
+ }
+
+ $visibility = $field['visibility'] ?? null;
+ if ( ! is_array( $visibility ) || ! array_key_exists( 'value', $visibility ) ) {
+ continue;
+ }
+
+ $controller = $visibility['controller'] ?? null;
+ if ( ! is_string( $controller ) ) {
+ continue;
+ }
+
+ $type = $controller_types[ $controller ] ?? null;
+ if ( ! is_string( $type ) || ! in_array( $type, self::TYPED_VALUE_FIELD_TYPES, true ) ) {
+ continue;
+ }
+
+ $original = $visibility['value'];
+ $canonical = self::canonicalize_typed_visibility_value( $original, $type, $controller );
+ if ( $original !== $canonical ) {
+ $field['visibility']['value'] = $canonical;
+ $converted_fields[] = $field['id'];
+ }
+ }
+ unset( $field );
+ }
+ unset( $group );
+ }
+
+ /**
+ * Canonicalize one visibility value or list of alternatives.
+ *
+ * @param mixed $value Visibility value.
+ * @param string $type Controller field type.
+ * @param string $controller_id Controller field id.
+ * @return mixed
+ */
+ private static function canonicalize_typed_visibility_value( $value, string $type, string $controller_id ) {
+ if ( is_array( $value ) ) {
+ $canonical = array();
+ foreach ( $value as $key => $item ) {
+ $canonical[ $key ] = 'array' === $type && ! is_array( $item )
+ ? $item
+ : self::canonicalize_typed_visibility_item( $item, $type, $controller_id );
+ }
+
+ return $canonical;
+ }
+
+ return self::canonicalize_typed_visibility_item( $value, $type, $controller_id );
+ }
+
+ /**
+ * Canonicalize one typed visibility alternative when it is compatible.
+ *
+ * @param mixed $value Visibility alternative.
+ * @param string $type Controller field type.
+ * @param string $controller_id Controller field id.
+ * @return mixed
+ */
+ private static function canonicalize_typed_visibility_item( $value, string $type, string $controller_id ) {
+ try {
+ switch ( $type ) {
+ case 'array':
+ return self::canonicalize_array_value( $value, $controller_id );
+ case 'checkbox':
+ return null === $value ? null : self::canonicalize_checkbox_value( $value, $controller_id );
+ case 'number':
+ return self::canonicalize_number( $value, false, $controller_id, 'visibility value' );
+ case 'integer':
+ return self::canonicalize_number( $value, true, $controller_id, 'visibility value' );
+ case 'datetime-local':
+ return self::canonicalize_datetime( $value, $controller_id );
+ }
+ } catch ( \InvalidArgumentException $e ) {
+ unset( $e );
+ // Preserve incompatible alternatives instead of narrowing the existing visibility contract.
+ return $value;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Whether a legacy number follows the HTML integer step contract.
+ *
+ * Promotion happens only when the step is 1 and every quantity the integer
+ * path will later canonicalize — the stored value and the min/max bounds — is
+ * itself integral. A step=1 control holding a decimal value or bound (which
+ * the classic sanitizer never rejected) stays a 'number' field, so integer
+ * canonicalization does not throw and collapse the section into the fallback.
+ *
+ * @param array $field Field definition.
+ * @return bool
+ */
+ private static function should_promote_to_integer( array $field ): bool {
+ $attributes = isset( $field['customAttributes'] ) && is_array( $field['customAttributes'] ) ? $field['customAttributes'] : array();
+ if ( ! array_key_exists( 'step', $attributes ) || '1' !== self::get_integral_decimal( $attributes['step'] ) ) {
+ return false;
+ }
+
+ $validation = isset( $field['validation'] ) && is_array( $field['validation'] ) ? $field['validation'] : array();
+ $candidates = array(
+ $field['value'] ?? null,
+ $attributes['min'] ?? null,
+ $attributes['max'] ?? null,
+ $validation['min'] ?? null,
+ $validation['max'] ?? null,
+ );
+
+ foreach ( $candidates as $candidate ) {
+ if ( null === $candidate || ( is_string( $candidate ) && '' === trim( $candidate ) ) ) {
+ // An absent or empty quantity imposes no integer constraint.
+ continue;
+ }
+ if ( null === self::get_integral_decimal( $candidate ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Canonicalize an array value to a string list.
+ *
+ * @param mixed $value Candidate value.
+ * @param string $field_id Field id.
+ * @return array
+ * @throws \InvalidArgumentException When the value cannot become a string list.
+ */
+ private static function canonicalize_array_value( $value, string $field_id ): array {
+ if ( is_string( $value ) ) {
+ return '' === $value ? array() : array( $value );
+ }
+
+ if ( ! is_array( $value ) || ! ArrayUtil::array_is_list( $value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" value must be a string list.', $field_id ) );
+ }
+
+ $canonical = array();
+ foreach ( $value as $item ) {
+ if ( ! is_scalar( $item ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" value must be a string list.', $field_id ) );
+ }
+ $canonical[] = is_string( $item ) ? $item : self::to_canonical_string( $item );
+ }
+
+ return $canonical;
+ }
+
+ /**
+ * Canonicalize a checkbox value.
+ *
+ * @param mixed $value Candidate value.
+ * @param string $field_id Field id.
+ * @return bool
+ * @throws \InvalidArgumentException When the value is ambiguous.
+ */
+ private static function canonicalize_checkbox_value( $value, string $field_id ): bool {
+ if ( is_bool( $value ) ) {
+ return $value;
+ }
+
+ if ( is_int( $value ) && in_array( $value, array( 0, 1 ), true ) ) {
+ return 1 === $value;
+ }
+
+ if ( is_string( $value ) ) {
+ $normalized = strtolower( trim( $value ) );
+ if ( in_array( $normalized, array( '1', 'true', 'yes' ), true ) ) {
+ return true;
+ }
+ if ( in_array( $normalized, array( '', '0', 'false', 'no' ), true ) ) {
+ return false;
+ }
+ }
+
+ throw self::invalid_schema( sprintf( 'Field "%s" checkbox value is ambiguous.', $field_id ) );
+ }
+
+ /**
+ * Canonicalize a number without first rounding integral strings.
+ *
+ * @param mixed $value Candidate value.
+ * @param bool $integer_only Whether the result must be an integer.
+ * @param string $field_id Field id.
+ * @param string $property Value or bound name.
+ * @return int|float|null
+ * @throws \InvalidArgumentException When the number is invalid, unsafe, or lossy.
+ */
+ private static function canonicalize_number( $value, bool $integer_only, string $field_id, string $property ) {
+ if ( null === $value || ( is_string( $value ) && '' === trim( $value ) ) ) {
+ return null;
+ }
+
+ if ( is_int( $value ) ) {
+ self::assert_safe_integer( (string) $value, $field_id, $property );
+ return $value;
+ }
+
+ if ( is_float( $value ) ) {
+ if ( ! is_finite( $value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s must be a finite number.', $field_id, $property ) );
+ }
+
+ if ( floor( $value ) === $value ) {
+ $integral = sprintf( '%.0f', $value );
+ self::assert_safe_integer( $integral, $field_id, $property );
+ return $integer_only ? (int) $integral : $value;
+ }
+
+ if ( $integer_only ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s must be an integer.', $field_id, $property ) );
+ }
+
+ return $value;
+ }
+
+ $integral = self::get_integral_decimal( $value );
+ if ( null !== $integral ) {
+ self::assert_safe_integer( $integral, $field_id, $property );
+
+ return (int) $integral;
+ }
+
+ if ( $integer_only ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s must be an integer.', $field_id, $property ) );
+ }
+
+ if ( ! is_string( $value ) || ! self::is_decimal_number( trim( $value ) ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s must be a finite number.', $field_id, $property ) );
+ }
+
+ $number = (float) trim( $value );
+ $encoded_number = wp_json_encode( $number, JSON_PRESERVE_ZERO_FRACTION );
+ if (
+ ! is_finite( $number ) ||
+ ( 0.0 === $number && ! self::decimal_string_is_zero( trim( $value ) ) ) ||
+ ! is_string( $encoded_number ) ||
+ ! self::decimal_strings_represent_same_value( trim( $value ), $encoded_number )
+ ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s cannot be represented as a finite number without loss.', $field_id, $property ) );
+ }
+
+ return $number;
+ }
+
+ /**
+ * Return the exact integral decimal represented by a numeric value.
+ *
+ * @param mixed $value Candidate numeric value.
+ * @return string|null Normalized signed integer, or null when non-integral.
+ */
+ private static function get_integral_decimal( $value ): ?string {
+ if ( is_int( $value ) ) {
+ return (string) $value;
+ }
+
+ if ( is_float( $value ) ) {
+ return is_finite( $value ) && floor( $value ) === $value ? sprintf( '%.0f', $value ) : null;
+ }
+
+ if ( ! is_string( $value ) ) {
+ return null;
+ }
+
+ $value = trim( $value );
+ if ( ! preg_match( self::DECIMAL_PATTERN, $value, $matches ) ) {
+ return null;
+ }
+
+ $whole = $matches['whole'] ?? '';
+ $fraction = '' !== ( $matches['fraction'] ?? '' ) ? $matches['fraction'] : ( $matches['bare_fraction'] ?? '' );
+ $digits = ltrim( $whole . $fraction, '0' );
+ $digits = '' === $digits ? '0' : $digits;
+ $exponent = $matches['exponent'] ?? '0';
+ $negative = '-' === $matches['sign'];
+ $scale = strlen( $fraction );
+
+ if ( '0' === $digits ) {
+ // Zero stays zero at every exponent, so return before expanding it and
+ // tripping the overflow sentinel below on values such as "0e17".
+ return '0';
+ }
+
+ if ( strlen( ltrim( $exponent, '+-0' ) ) > 6 ) {
+ return '-' === substr( $exponent, 0, 1 ) ? null : ( $negative ? '-' : '' ) . str_repeat( '9', 17 );
+ }
+
+ $decimal_places = $scale - (int) $exponent;
+ if ( 0 < $decimal_places ) {
+ if ( $decimal_places >= strlen( $digits ) ) {
+ return null;
+ }
+
+ $trailing = substr( $digits, -$decimal_places );
+ if ( '' !== trim( $trailing, '0' ) ) {
+ return null;
+ }
+ $digits = substr( $digits, 0, -$decimal_places );
+ } elseif ( 0 > $decimal_places ) {
+ $zeros = -$decimal_places;
+ if ( 17 < strlen( $digits ) + $zeros ) {
+ $digits = str_repeat( '9', 17 );
+ } else {
+ $digits .= str_repeat( '0', $zeros );
+ }
+ }
+
+ $digits = ltrim( $digits, '0' );
+ if ( '' === $digits ) {
+ return '0';
+ }
+
+ return $negative ? '-' . $digits : $digits;
+ }
+
+ /**
+ * Assert an exact integer fits both JavaScript and the current PHP runtime.
+ *
+ * @param string $integer Normalized signed integer.
+ * @param string $field_id Field id.
+ * @param string $property Value or bound name.
+ * @throws \InvalidArgumentException When the integer is unsafe.
+ */
+ private static function assert_safe_integer( string $integer, string $field_id, string $property ): void {
+ $absolute = ltrim( $integer, '-' );
+ if ( self::unsigned_decimal_is_greater( $absolute, self::JAVASCRIPT_SAFE_INTEGER ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s is outside the JavaScript safe integer range.', $field_id, $property ) );
+ }
+
+ $php_integer_limit = '-' === substr( $integer, 0, 1 ) ? ltrim( (string) PHP_INT_MIN, '-' ) : (string) PHP_INT_MAX;
+ if ( self::unsigned_decimal_is_greater( $absolute, $php_integer_limit ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s cannot be represented as an integer on this PHP platform.', $field_id, $property ) );
+ }
+ }
+
+ /**
+ * Compare normalized unsigned decimal strings.
+ *
+ * @param string $left Left operand.
+ * @param string $right Right operand.
+ * @return bool
+ */
+ private static function unsigned_decimal_is_greater( string $left, string $right ): bool {
+ $left = ltrim( $left, '0' );
+ $right = ltrim( $right, '0' );
+ $left = '' === $left ? '0' : $left;
+ $right = '' === $right ? '0' : $right;
+
+ return strlen( $left ) !== strlen( $right ) ? strlen( $left ) > strlen( $right ) : 0 < strcmp( $left, $right );
+ }
+
+ /**
+ * Whether a string follows the HTML decimal-number grammar.
+ *
+ * @param string $value Candidate number.
+ * @return bool
+ */
+ private static function is_decimal_number( string $value ): bool {
+ return 1 === preg_match( self::DECIMAL_PATTERN, $value );
+ }
+
+ /**
+ * Whether a decimal numeric string represents zero.
+ *
+ * @param string $value Candidate number.
+ * @return bool
+ */
+ private static function decimal_string_is_zero( string $value ): bool {
+ $mantissa = substr( $value, 0, strcspn( $value, 'eE' ) );
+ return '' === trim( $mantissa, '+-0.' );
+ }
+
+ /**
+ * Whether two decimal strings represent the same normalized value.
+ *
+ * @param string $left Left decimal value.
+ * @param string $right Right decimal value.
+ * @return bool
+ */
+ private static function decimal_strings_represent_same_value( string $left, string $right ): bool {
+ $normalized_left = self::normalize_decimal_string( $left );
+ $normalized_right = self::normalize_decimal_string( $right );
+
+ return null !== $normalized_left && $normalized_left === $normalized_right;
+ }
+
+ /**
+ * Normalize a decimal string to its significant digits and base-ten power.
+ *
+ * @param string $value Decimal value.
+ * @return array{string, int}|null Normalized signed digits and power, or null when invalid.
+ */
+ private static function normalize_decimal_string( string $value ): ?array {
+ if ( ! preg_match( self::DECIMAL_PATTERN, $value, $matches ) ) {
+ return null;
+ }
+
+ $whole = $matches['whole'] ?? '';
+ $fraction = '' !== ( $matches['fraction'] ?? '' ) ? $matches['fraction'] : ( $matches['bare_fraction'] ?? '' );
+ $digits = ltrim( $whole . $fraction, '0' );
+ if ( '' === $digits ) {
+ return array( '0', 0 );
+ }
+
+ $exponent = $matches['exponent'] ?? '0';
+ $exponent_digits = ltrim( $exponent, '+-0' );
+ if ( 6 < strlen( $exponent_digits ) ) {
+ return null;
+ }
+
+ $power = (int) $exponent - strlen( $fraction );
+ $trimmed_digits = rtrim( $digits, '0' );
+ $power += strlen( $digits ) - strlen( $trimmed_digits );
+ $signed_digits = '-' === $matches['sign'] ? '-' . $trimmed_digits : $trimmed_digits;
+
+ return array( $signed_digits, $power );
+ }
+
+ /**
+ * Canonicalize numeric validation and mirror it to legacy attributes.
+ *
+ * @param array $field Field definition.
+ * @param bool $integer_only Whether bounds must be integers.
+ * @return bool Whether provider-supplied metadata required compatibility conversion.
+ * @throws \InvalidArgumentException When bounds are invalid or disagree.
+ */
+ private static function canonicalize_numeric_validation( array &$field, bool $integer_only ): bool {
+ $attributes = $field['customAttributes'] ?? array();
+ $validation = $field['validation'] ?? array();
+ $converted = false;
+
+ if ( ! is_array( $attributes ) || ! is_array( $validation ) ) {
+ return false;
+ }
+
+ foreach ( array( 'min', 'max' ) as $bound ) {
+ $has_attribute = array_key_exists( $bound, $attributes );
+ $has_validation = array_key_exists( $bound, $validation );
+ $empty_validation = $has_validation && is_string( $validation[ $bound ] ) && '' === trim( $validation[ $bound ] );
+
+ if ( $has_attribute && is_string( $attributes[ $bound ] ) && '' === trim( $attributes[ $bound ] ) ) {
+ unset( $attributes[ $bound ] );
+ $has_attribute = false;
+ }
+ if ( $empty_validation ) {
+ unset( $validation[ $bound ] );
+ $has_validation = false;
+ }
+ if ( ! $has_attribute && ! $has_validation ) {
+ $converted = $converted || $empty_validation;
+ continue;
+ }
+
+ $original_attribute = $has_attribute ? $attributes[ $bound ] : null;
+ $original_validation = $has_validation ? $validation[ $bound ] : null;
+ $attribute_value = $has_attribute ? self::canonicalize_number( $original_attribute, $integer_only, $field['id'], $bound ) : null;
+ $validation_value = $has_validation ? self::canonicalize_number( $original_validation, $integer_only, $field['id'], $bound ) : null;
+
+ if ( $has_attribute && $has_validation && ! self::numeric_values_agree( $attribute_value, $validation_value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" %2$s disagrees between customAttributes and validation.', $field['id'], $bound ) );
+ }
+
+ $canonical = $has_validation ? $validation_value : $attribute_value;
+ $validation[ $bound ] = $canonical;
+ $attributes[ $bound ] = $canonical;
+ $converted = $converted ||
+ $empty_validation ||
+ ( $has_attribute && ! $has_validation ) ||
+ ( $has_attribute && $has_validation && $original_attribute !== $attribute_value ) ||
+ ( $has_validation && $original_validation !== $validation_value );
+ }
+
+ if ( ! empty( $validation ) ) {
+ $field['validation'] = $validation;
+ } else {
+ unset( $field['validation'] );
+ }
+ if ( ! empty( $attributes ) ) {
+ $field['customAttributes'] = $attributes;
+ } else {
+ unset( $field['customAttributes'] );
+ }
+
+ return $converted;
+ }
+
+ /**
+ * Whether two numeric values describe the same number.
+ *
+ * @param mixed $left Left operand.
+ * @param mixed $right Right operand.
+ * @return bool
+ */
+ private static function numeric_values_agree( $left, $right ): bool {
+ if ( ! is_numeric( $left ) || ! is_numeric( $right ) ) {
+ return false;
+ }
+
+ return (float) $left === (float) $right;
+ }
- if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) || ! ArrayUtil::array_is_list( $group['fields'] ) ) {
- throw self::invalid_schema( sprintf( 'Group "%s" fields must be a list.', $group_id ) );
- }
+ /**
+ * Canonicalize a store-local or already-qualified datetime.
+ *
+ * @param mixed $value Candidate value.
+ * @param string $field_id Field id.
+ * @return string|null
+ * @throws \InvalidArgumentException When the datetime is malformed.
+ */
+ private static function canonicalize_datetime( $value, string $field_id ): ?string {
+ if ( null === $value || ( is_string( $value ) && '' === trim( $value ) ) ) {
+ return null;
+ }
- foreach ( $group['fields'] as $field_index => $field ) {
- if ( ! is_array( $field ) ) {
- throw self::invalid_schema( sprintf( 'Group "%s" field %d must be an array.', $group_id, $field_index ) );
- }
+ if ( ! is_string( $value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" datetime value must be a string or null.', $field_id ) );
+ }
- self::assert_non_empty_string( $field['id'] ?? null, sprintf( 'Group "%s" field %d id must be a non-empty string.', $group_id, $field_index ) );
- $field_id = $field['id'];
- if ( isset( $field_ids[ $field_id ] ) ) {
- throw self::invalid_schema( sprintf( 'Field id "%s" is duplicated.', $field_id ) );
- }
- if ( isset( $schema['groups'][ $field_id ] ) ) {
- throw self::invalid_schema( sprintf( 'Field id "%s" collides with a group id.', $field_id ) );
- }
+ $value = trim( $value );
+ // Seconds occupy index 16 (`:ss`) for both local and qualified values.
+ $has_seconds = isset( $value[16] ) && ':' === $value[16];
+ $wall_format = $has_seconds ? 'Y-m-d\TH:i:s' : 'Y-m-d\TH:i';
- $field_ids[ $field_id ] = true;
- self::assert_field( $field );
- if ( isset( $field['visibility'] ) ) {
- $visibility_rules[ $field_id ] = $field['visibility'];
- }
- }
+ if ( preg_match( self::LOCAL_DATETIME_PATTERN, $value ) ) {
+ $format = '!' . $wall_format;
+ $datetime = \DateTimeImmutable::createFromFormat( $format, $value, wp_timezone() );
+ } elseif ( preg_match( self::QUALIFIED_DATETIME_PATTERN, $value ) ) {
+ $format = '!' . $wall_format . 'P';
+ $datetime = \DateTimeImmutable::createFromFormat( $format, $value );
+ } else {
+ throw self::invalid_schema( sprintf( 'Field "%s" datetime value is malformed.', $field_id ) );
}
- foreach ( $visibility_rules as $field_id => $visibility ) {
- $controller = $visibility['controller'];
- if ( ! isset( $field_ids[ $controller ] ) ) {
- throw self::invalid_schema( sprintf( 'Field "%s" visibility controller "%s" does not reference a field.', $field_id, $controller ) );
- }
+ $errors = \DateTimeImmutable::getLastErrors();
+ if ( false === $datetime || ( is_array( $errors ) && ( 0 < $errors['warning_count'] || 0 < $errors['error_count'] ) ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" datetime value is malformed.', $field_id ) );
}
+
+ return $datetime->format( 'Y-m-d\TH:i:sP' );
}
/**
- * Canonicalize option values supplied by native Settings UI schema providers.
- *
- * Schemas built from legacy settings always carry string option values, but
- * native providers can supply any scalar. The client matches options against
- * the stored value with strict string comparison, so scalar option values,
- * the selected values they match, and visibility values compared against
- * them are cast here to the string the client's own String() coercion
- * produces. Malformed entries remain unchanged for the provider to fix.
- *
- * @since 11.0.0
+ * Preserve valid pre-conversion form values for changed fields.
*
- * @param array $schema Settings UI schema.
- * @return array Schema with scalar option values canonicalized to strings.
+ * @param array $schema Canonical schema.
+ * @param array $original_values Original values keyed by field id.
+ * @param array<string, bool> $fields_requiring_preservation Fields changed by typed canonicalization.
+ * @throws \InvalidArgumentException When a converted value has no safe form representation.
*/
- public static function canonicalize_option_values( array $schema ): array {
- if ( ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
- return $schema;
+ private static function preserve_converted_form_values( array &$schema, array $original_values, array $fields_requiring_preservation ): void {
+ $page_save_adapter = isset( $schema['save'] ) && is_array( $schema['save'] ) ? ( $schema['save']['adapter'] ?? 'form_post' ) : 'form_post';
+ if ( 'form_post' !== $page_save_adapter ) {
+ return;
}
- $converted_fields = array();
- $option_field_ids = array();
-
foreach ( $schema['groups'] as &$group ) {
if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
continue;
@@ -262,96 +1200,151 @@ class SettingsUISchema {
foreach ( $group['fields'] as &$field ) {
if (
! is_array( $field ) ||
- ! isset( $field['id'], $field['options'] ) ||
- ! is_string( $field['id'] ) ||
- ! is_array( $field['options'] )
+ ! isset( $field['id'] ) ||
+ ! isset( $fields_requiring_preservation[ $field['id'] ] ) ||
+ ! array_key_exists( $field['id'], $original_values )
) {
continue;
}
- $option_field_ids[] = $field['id'];
- $converted = false;
-
- foreach ( $field['options'] as &$option ) {
- if (
- ! is_array( $option ) ||
- ! array_key_exists( 'value', $option ) ||
- is_string( $option['value'] ) ||
- ! is_scalar( $option['value'] )
- ) {
- continue;
- }
+ $original = $original_values[ $field['id'] ];
+ if ( ( $field['value'] ?? null ) === $original || 'form_post' !== self::get_field_save_adapter( $field ) ) {
+ continue;
+ }
- $option['value'] = self::to_canonical_string( $option['value'] );
- $converted = true;
+ if ( isset( $field['save'] ) && is_array( $field['save'] ) && array_key_exists( 'initialValue', $field['save'] ) ) {
+ continue;
}
- unset( $option );
- if ( array_key_exists( 'value', $field ) ) {
- if ( is_scalar( $field['value'] ) && ! is_string( $field['value'] ) ) {
- $field['value'] = self::to_canonical_string( $field['value'] );
- $converted = true;
- } elseif ( is_array( $field['value'] ) ) {
- $canonical_list = self::canonicalize_scalar_list( $field['value'] );
- if ( null !== $canonical_list ) {
- $field['value'] = $canonical_list;
- $converted = true;
- }
- }
+ $form_value = 'array' === ( $field['type'] ?? null ) && '' === $original ? array() : $original;
+ if ( ! self::is_form_value_for_field( $form_value, $field ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" must define save.initialValue because its original value cannot be replayed safely through classic form-post semantics.', $field['id'] ) );
}
- if ( $converted ) {
- $converted_fields[] = $field['id'];
+ if ( ! isset( $field['save'] ) || ! is_array( $field['save'] ) ) {
+ $field['save'] = array( 'adapter' => 'form_post' );
}
+ $field['save']['initialValue'] = $form_value;
}
unset( $field );
}
unset( $group );
+ }
- foreach ( $schema['groups'] as &$group ) {
- if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
- continue;
+ /**
+ * Get a field's effective save adapter.
+ *
+ * @param array $field Field definition.
+ * @return mixed
+ */
+ private static function get_field_save_adapter( array $field ) {
+ return isset( $field['save'] ) && is_array( $field['save'] ) ? ( $field['save']['adapter'] ?? 'form_post' ) : 'form_post';
+ }
+
+ /**
+ * Whether a value is a valid HTML form representation.
+ *
+ * @param mixed $value Candidate value.
+ * @return bool
+ */
+ private static function is_form_value( $value ): bool {
+ if ( is_string( $value ) ) {
+ return true;
+ }
+
+ if ( ! is_array( $value ) || ! ArrayUtil::array_is_list( $value ) ) {
+ return false;
+ }
+
+ foreach ( $value as $item ) {
+ if ( ! is_string( $item ) ) {
+ return false;
}
+ }
- foreach ( $group['fields'] as &$field ) {
- if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_string( $field['id'] ) ) {
- continue;
- }
+ return true;
+ }
- if ( ! self::is_canonicalizable_visibility_rule( $field['visibility'] ?? null, $option_field_ids ) ) {
- continue;
- }
+ /**
+ * Whether a value can be replayed safely for a field through classic
+ * form-post semantics.
+ *
+ * @param mixed $value Candidate form value.
+ * @param array $field Canonical field definition.
+ * @return bool
+ */
+ private static function is_form_value_for_field( $value, array $field ): bool {
+ if ( ! self::is_form_value( $value ) ) {
+ return false;
+ }
- $rule_value = $field['visibility']['value'];
+ if ( 'array' !== ( $field['type'] ?? null ) && ! is_string( $value ) ) {
+ return false;
+ }
- if ( is_scalar( $rule_value ) && ! is_string( $rule_value ) ) {
- $field['visibility']['value'] = self::to_canonical_string( $rule_value );
- $converted_fields[] = $field['id'];
- } elseif ( is_array( $rule_value ) ) {
- $canonical_list = self::canonicalize_scalar_list( $rule_value );
- if ( null !== $canonical_list ) {
- $field['visibility']['value'] = $canonical_list;
- $converted_fields[] = $field['id'];
- }
- }
+ $type = $field['type'] ?? null;
+ if ( ! is_string( $type ) || ! in_array( $type, self::TYPED_VALUE_FIELD_TYPES, true ) ) {
+ return array_key_exists( 'value', $field ) && $value === $field['value'];
+ }
+ if ( ! array_key_exists( 'value', $field ) ) {
+ return false;
+ }
+
+ try {
+ switch ( $type ) {
+ case 'array':
+ $canonical_value = self::canonicalize_array_value( $value, $field['id'] );
+ break;
+ case 'checkbox':
+ $canonical_value = '1' === $value || 'yes' === $value;
+ break;
+ case 'number':
+ $canonical_value = self::canonicalize_number( $value, false, $field['id'], 'save.initialValue' );
+ break;
+ case 'integer':
+ $canonical_value = self::canonicalize_number( $value, true, $field['id'], 'save.initialValue' );
+ break;
+ case 'datetime-local':
+ $canonical_value = self::canonicalize_datetime( $value, $field['id'] );
+ break;
+ default:
+ return true;
}
- unset( $field );
+ } catch ( \InvalidArgumentException $exception ) {
+ unset( $exception );
+ return false;
}
- unset( $group );
- if ( ! empty( $converted_fields ) ) {
- wc_doing_it_wrong(
- __METHOD__,
- sprintf(
- /* translators: %s: comma-separated field ids. */
- esc_html__( 'A Settings UI schema provider supplied non-string option, field, or visibility values that WooCommerce converted for compatibility: %s. Update the provider to supply string values.', 'woocommerce' ),
- esc_html( implode( ', ', array_unique( $converted_fields ) ) )
- ),
- '11.0.0'
- );
+ $current_value = $field['value'];
+ if ( 'number' === $type && null !== $canonical_value && null !== $current_value ) {
+ return self::numeric_values_agree( $canonical_value, $current_value );
}
- return $schema;
+ return $canonical_value === $current_value;
+ }
+
+ /**
+ * Emit one compatibility notice for all affected fields.
+ *
+ * @param string $method Method name reported by the notice.
+ * @param string[] $field_ids Affected field ids.
+ * @param string $message Translatable sprintf message.
+ * @param string $version Version when the notice was introduced.
+ */
+ private static function emit_conversion_notice( string $method, array $field_ids, string $message, string $version ): void {
+ if ( empty( $field_ids ) ) {
+ return;
+ }
+
+ wc_doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: %s: comma-separated field ids. */
+ esc_html( $message ),
+ esc_html( implode( ', ', array_unique( $field_ids ) ) )
+ ),
+ $version
+ );
}
/**
@@ -383,14 +1376,17 @@ class SettingsUISchema {
/**
* Whether a visibility rule carries a value compared against an options field.
*
- * @param mixed $rule Candidate visibility rule.
- * @param array $option_field_ids Ids of fields carrying an options array.
+ * @param mixed $rule Candidate visibility rule.
+ * @param array<string, bool> $option_field_ids Ids of fields carrying an options array.
* @return bool
*/
private static function is_canonicalizable_visibility_rule( $rule, array $option_field_ids ): bool {
+ $controller = is_array( $rule ) ? ( $rule['controller'] ?? null ) : null;
+
return is_array( $rule )
&& array_key_exists( 'value', $rule )
- && in_array( $rule['controller'] ?? null, $option_field_ids, true );
+ && is_string( $controller )
+ && isset( $option_field_ids[ $controller ] );
}
/**
@@ -432,15 +1428,24 @@ class SettingsUISchema {
}
$canonical_type = self::normalize_type( $type );
- $field = array(
+ $save = self::get_save_schema( $setting, $default_save_adapter );
+ if ( 'info' === $type ) {
+ $save = array( 'adapter' => 'none' );
+ }
+ $raw_value = self::get_field_raw_value( $setting, $save );
+ $field = array(
'id' => $id,
'label' => self::get_field_label( $setting, $id, $type ),
'type' => $canonical_type,
'description' => self::get_field_description( $setting, $type ),
- 'value' => self::get_field_value( $setting, $canonical_type ),
- 'save' => self::get_save_schema( $setting, $default_save_adapter ),
+ 'value' => $raw_value,
+ 'save' => $save,
);
+ if ( 'form_post' === ( $save['adapter'] ?? null ) && ! array_key_exists( 'initialValue', $save ) && self::is_form_value( $raw_value ) ) {
+ $field['save']['initialValue'] = 'array' === $canonical_type && '' === $raw_value ? array() : $raw_value;
+ }
+
foreach ( array( 'component', 'placeholder', 'disabled' ) as $key ) {
if ( array_key_exists( $key, $setting ) ) {
$field[ $key ] = $setting[ $key ];
@@ -538,39 +1543,41 @@ class SettingsUISchema {
}
/**
- * Get a field value.
+ * Get the raw value for a legacy field.
*
- * @param array $setting Legacy field definition.
- * @param string $type Canonical field type.
+ * Option-backed values are read only for legacy form-post fields. The option
+ * reader supports flat and one-level nested names.
+ *
+ * @param array $setting Legacy field definition.
+ * @param array $save Field save metadata.
* @return mixed
+ * @throws \InvalidArgumentException When the effective field name is unsupported.
*/
- private static function get_field_value( array $setting, string $type ) {
+ private static function get_field_raw_value( array $setting, array $save ) {
if ( array_key_exists( 'value', $setting ) ) {
- return self::normalize_value( $setting['value'], $type );
+ return $setting['value'];
}
$default = $setting['default'] ?? '';
- $value = \WC_Admin_Settings::get_option( (string) $setting['id'], $default );
+ if ( 'form_post' !== ( $save['adapter'] ?? null ) ) {
+ return $default;
+ }
- return self::normalize_value( $value, $type );
- }
+ $field_name = $save['name'] ?? $setting['id'] ?? '';
+ if ( ! is_string( $field_name ) || '' === $field_name ) {
+ throw self::invalid_schema( 'A legacy form-post field must define a non-empty field name.' );
+ }
- /**
- * Normalize a value for the canonical schema.
- *
- * @param mixed $value Field value.
- * @param string $type Canonical type.
- * @return mixed
- */
- private static function normalize_value( $value, string $type ) {
- switch ( $type ) {
- case 'array':
- return is_array( $value ) ? array_values( $value ) : array();
- case 'checkbox':
- return function_exists( 'wc_string_to_bool' ) ? wc_string_to_bool( $value ) : (bool) $value;
- default:
- return $value;
+ $type = isset( $setting['type'] ) && is_string( $setting['type'] ) ? self::normalize_type( $setting['type'] ) : 'text';
+ if ( 'array' === $type && '[]' === substr( $field_name, -2 ) ) {
+ $field_name = substr( $field_name, 0, -2 );
+ }
+
+ if ( ! self::is_supported_form_post_name( $field_name, false ) ) {
+ throw self::invalid_schema( sprintf( 'Legacy form-post field "%s" has an unsupported name.', $field_name ) );
}
+
+ return woocommerce_settings_get_option( $field_name, $default );
}
/**
@@ -589,7 +1596,7 @@ class SettingsUISchema {
return array( 'adapter' => 'none' );
}
- $field_name = isset( $setting['field_name'] ) && is_scalar( $setting['field_name'] )
+ $field_name = isset( $setting['field_name'] ) && is_scalar( $setting['field_name'] ) && '' !== (string) $setting['field_name']
? (string) $setting['field_name']
: (string) $setting['id'];
@@ -975,6 +1982,7 @@ class SettingsUISchema {
self::assert_field_value( $field );
self::assert_field_options( $field );
self::assert_custom_attributes( $field );
+ self::assert_field_validation( $field );
self::assert_field_save( $field );
self::assert_visibility( $field );
}
@@ -989,8 +1997,32 @@ class SettingsUISchema {
return;
}
- if ( ! self::is_settings_value( $field['value'] ) ) {
- throw self::invalid_schema( sprintf( 'Field "%s" value is not a valid Settings UI value.', $field['id'] ) );
+ $value = $field['value'];
+ switch ( $field['type'] ) {
+ case 'array':
+ $valid = is_array( $value ) && self::is_form_value( $value );
+ break;
+ case 'checkbox':
+ $valid = is_bool( $value );
+ break;
+ case 'number':
+ $valid = self::is_canonical_number( $value );
+ break;
+ case 'integer':
+ $valid = null === $value || ( is_int( $value ) && self::is_canonical_number( $value ) );
+ break;
+ case 'datetime-local':
+ $valid = null === $value || ( is_string( $value ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', $value ) );
+ break;
+ default:
+ $valid = in_array( $field['type'], self::SUPPORTED_FIELD_TYPES, true )
+ ? is_string( $value )
+ : self::is_settings_value( $value );
+ break;
+ }
+
+ if ( ! $valid ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" value is invalid for type "%s".', $field['id'], $field['type'] ) );
}
}
@@ -1046,6 +2078,66 @@ class SettingsUISchema {
if ( ! is_scalar( $value ) || ( is_float( $value ) && ! is_finite( $value ) ) ) {
throw self::invalid_schema( sprintf( 'Field "%s" custom attribute "%s" has an invalid value.', $field['id'], $attribute ) );
}
+
+ if ( in_array( $attribute, self::RANGE_ATTRIBUTES, true ) ) {
+ $is_numeric_field = in_array( $field['type'], array( 'number', 'integer' ), true );
+ $is_temporal_field = in_array( $field['type'], self::TEMPORAL_RANGE_FIELD_TYPES, true );
+ if ( ! $is_numeric_field && ! $is_temporal_field && in_array( $field['type'], self::SUPPORTED_FIELD_TYPES, true ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" may define "%s" only when its type supports range attributes.', $field['id'], $attribute ) );
+ }
+ if ( ! $is_numeric_field ) {
+ continue;
+ }
+
+ $allow_any = 'step' === $attribute;
+ $is_any = $allow_any && is_string( $value ) && 0 === strcasecmp( $value, 'any' );
+ $is_integer_field = 'integer' === $field['type'];
+ $valid = $allow_any
+ ? $is_any || ( self::is_finite_number( $value ) && ( $is_integer_field || 0 < (float) $value ) )
+ : self::is_canonical_number( $value );
+ if ( ! $valid ) {
+ $message = $allow_any
+ ? sprintf( 'Field "%s" custom attribute "step" must be a positive finite number or "any".', $field['id'] )
+ : sprintf( 'Field "%s" custom attribute "%s" must be a finite number.', $field['id'], $attribute );
+ throw self::invalid_schema( $message );
+ }
+
+ if ( 'integer' === $field['type'] && 'step' === $attribute ) {
+ $integer_step = self::get_integral_decimal( $value );
+ if ( null === $integer_step || '0' === $integer_step || '-' === substr( $integer_step, 0, 1 ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" custom attribute "step" must be a positive integer.', $field['id'] ) );
+ }
+ }
+
+ if ( 'integer' === $field['type'] && in_array( $attribute, array( 'min', 'max' ), true ) && ! is_int( $value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" custom attribute "%s" must be an integer.', $field['id'], $attribute ) );
+ }
+ }
+ }
+ }
+
+ /**
+ * Assert canonical field validation metadata.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_field_validation( array $field ): void {
+ if ( ! array_key_exists( 'validation', $field ) ) {
+ return;
+ }
+
+ if ( ! is_array( $field['validation'] ) || ! in_array( $field['type'], array( 'number', 'integer' ), true ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" validation is supported only for numeric fields.', $field['id'] ) );
+ }
+
+ foreach ( $field['validation'] as $rule => $value ) {
+ if ( ! in_array( $rule, array( 'min', 'max' ), true ) || null === $value || ! self::is_canonical_number( $value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" validation rule "%2$s" must be a finite numeric bound.', $field['id'], (string) $rule ) );
+ }
+
+ if ( 'integer' === $field['type'] && ! is_int( $value ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%1$s" validation rule "%2$s" must be an integer.', $field['id'], $rule ) );
+ }
}
}
@@ -1060,7 +2152,7 @@ class SettingsUISchema {
throw self::invalid_schema( sprintf( 'Field "%s" save metadata must be an array.', $field['id'] ) );
}
- $adapter = is_array( $save ) ? ( $save['adapter'] ?? null ) : 'form_post';
+ $adapter = self::get_field_save_adapter( $field );
if ( ! is_string( $adapter ) || ! in_array( $adapter, array( 'form_post', 'none' ), true ) ) {
throw self::invalid_schema( sprintf( 'Field "%s" save adapter must be "form_post" or "none".', $field['id'] ) );
}
@@ -1069,11 +2161,44 @@ class SettingsUISchema {
self::assert_non_empty_string( $save['name'], sprintf( 'Field "%s" save name must be a non-empty string.', $field['id'] ) );
}
+ if ( 'form_post' === $adapter ) {
+ $name = is_array( $save ) && array_key_exists( 'name', $save ) ? $save['name'] : $field['id'];
+ if ( ! self::is_supported_form_post_name( $name, 'array' === $field['type'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" save name "%s" is not a supported form-post field name.', $field['id'], $name ) );
+ }
+ }
+
+ if ( is_array( $save ) && array_key_exists( 'initialValue', $save ) ) {
+ if ( ! self::is_form_value( $save['initialValue'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" save.initialValue must be a string or string list.', $field['id'] ) );
+ }
+
+ if ( ! self::is_form_value_for_field( $save['initialValue'], $field ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" save.initialValue cannot be replayed safely through classic form-post semantics.', $field['id'] ) );
+ }
+ }
+
if ( 'info' === $field['type'] && 'none' !== $adapter ) {
throw self::invalid_schema( sprintf( 'Field "%s" of type "info" must use the "none" save adapter.', $field['id'] ) );
}
}
+ /**
+ * Whether a field name can be serialized by the form-post adapter.
+ *
+ * @param mixed $name Candidate field name.
+ * @param bool $is_array Whether the field posts a string list.
+ * @return bool
+ */
+ private static function is_supported_form_post_name( $name, bool $is_array ): bool {
+ if ( ! is_string( $name ) || '' === $name ) {
+ return false;
+ }
+
+ $base_name = $is_array && '[]' === substr( $name, -2 ) ? substr( $name, 0, -2 ) : $name;
+ return 1 === preg_match( '/^[^\[\]]+(?:\[[^\[\]]+\])?$/', $base_name );
+ }
+
/**
* Assert field visibility metadata.
*
@@ -1130,7 +2255,63 @@ class SettingsUISchema {
return is_finite( $value );
}
- return is_array( $value ) && ArrayUtil::array_is_list( $value ) && count( $value ) === count( array_filter( $value, 'is_string' ) );
+ return is_array( $value ) && self::is_form_value( $value );
+ }
+
+ /**
+ * Whether a value is a finite number accepted by the renderer.
+ *
+ * @param mixed $value Candidate value.
+ * @return bool
+ */
+ private static function is_finite_number( $value ): bool {
+ if ( is_int( $value ) ) {
+ return true;
+ }
+
+ if ( is_float( $value ) ) {
+ return is_finite( $value );
+ }
+
+ if ( ! is_string( $value ) ) {
+ return false;
+ }
+
+ return is_numeric( $value ) && is_finite( (float) $value );
+ }
+
+ /**
+ * Whether a value satisfies the final JavaScript number contract.
+ *
+ * @param mixed $value Candidate value.
+ * @return bool
+ */
+ private static function is_canonical_number( $value ): bool {
+ if ( null === $value ) {
+ return true;
+ }
+
+ if ( is_int( $value ) ) {
+ /**
+ * Unsigned decimal representation.
+ *
+ * @var string $absolute
+ */
+ $absolute = ltrim( (string) $value, '-' );
+ return ! self::unsigned_decimal_is_greater( $absolute, self::JAVASCRIPT_SAFE_INTEGER );
+ }
+
+ if ( ! is_float( $value ) || ! is_finite( $value ) ) {
+ return false;
+ }
+
+ /**
+ * Unsigned decimal representation.
+ *
+ * @var string $absolute
+ */
+ $absolute = ltrim( sprintf( '%.0f', $value ), '-' );
+ return floor( $value ) !== $value || ! self::unsigned_decimal_is_greater( $absolute, self::JAVASCRIPT_SAFE_INTEGER );
}
/**
diff --git a/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts b/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
index f079de3f130..88c33951943 100644
--- a/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
@@ -4,6 +4,7 @@
import { expect, test, tags, request } from '../../fixtures/fixtures';
import { ADMIN_STATE_PATH } from '../../playwright.config';
import { setFeatureFlag, resetFeatureFlags } from '../../utils/features';
+import { setOption } from '../../utils/options';
import { wpCLI } from '../../utils/cli';
const compatibilityFailureFragments = [
@@ -25,6 +26,18 @@ const isCompatibilityFailure = ( message: string ): boolean => {
);
};
+const recordCompatibilityFailure = (
+ failures: string[],
+ message: string
+): void => {
+ if ( isCompatibilityFailure( message ) ) {
+ failures.push( message );
+ }
+};
+
+const getUpdatedWeightUnit = ( originalUnit: string ): string =>
+ originalUnit === 'kg' ? 'g' : 'kg';
+
const getBaseURL = ( baseURL: string | undefined ): string => {
if ( ! baseURL ) {
throw new Error( 'Expected baseURL to be configured.' );
@@ -33,6 +46,43 @@ const getBaseURL = ( baseURL: string | undefined ): string => {
return baseURL;
};
+const getPostedFormValue = (
+ postData: string | null,
+ name: string
+): string | null => {
+ if ( ! postData ) {
+ return null;
+ }
+
+ const urlEncodedMarker = `${ name }=`;
+ const urlEncodedIndex = postData.indexOf( urlEncodedMarker );
+ if ( urlEncodedIndex !== -1 ) {
+ const start = urlEncodedIndex + urlEncodedMarker.length;
+ const end = postData.indexOf( '&', start );
+ return decodeURIComponent(
+ end === -1 ? postData.slice( start ) : postData.slice( start, end )
+ );
+ }
+
+ const multipartName = `name="${ name }"`;
+ const nameIndex = postData.indexOf( multipartName );
+ if ( nameIndex === -1 ) {
+ return null;
+ }
+
+ const valueStart = postData.indexOf( '\r\n\r\n', nameIndex );
+ if ( valueStart === -1 ) {
+ return null;
+ }
+
+ const valueEnd = postData.indexOf( '\r\n', valueStart + 4 );
+ if ( valueEnd === -1 ) {
+ return null;
+ }
+
+ return postData.slice( valueStart + 4, valueEnd );
+};
+
test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
test.use( { storageState: ADMIN_STATE_PATH } );
@@ -49,6 +99,15 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
const url = getBaseURL( baseURL );
await resetFeatureFlags( request, url );
+ await setOption( request, url, 'woocommerce_enable_reviews', 'yes' );
+ await setOption( request, url, 'woocommerce_manage_stock', 'yes' );
+ await setOption( request, url, 'woocommerce_hold_stock_minutes', '60' );
+ await setOption(
+ request,
+ url,
+ 'woocommerce_notify_low_stock_amount',
+ '2'
+ );
await wpCLI(
'wp plugin deactivate settings-ui-component-registration --skip-plugins'
);
@@ -58,17 +117,12 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
page,
} ) => {
const compatibilityFailures: string[] = [];
- const recordCompatibilityFailure = ( message: string ) => {
- if ( isCompatibilityFailure( message ) ) {
- compatibilityFailures.push( message );
- }
- };
page.on( 'pageerror', ( error ) => {
- recordCompatibilityFailure( error.message );
+ recordCompatibilityFailure( compatibilityFailures, error.message );
} );
page.on( 'console', ( message ) => {
- recordCompatibilityFailure( message.text() );
+ recordCompatibilityFailure( compatibilityFailures, message.text() );
} );
await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
@@ -122,7 +176,7 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
const weightUnit = settingsUI.getByLabel( 'Weight unit' );
const originalUnit = await weightUnit.inputValue();
- const updatedUnit = originalUnit === 'kg' ? 'g' : 'kg';
+ const updatedUnit = getUpdatedWeightUnit( originalUnit );
const saveButton = settingsUI.getByRole( 'button', { name: 'Save' } );
try {
@@ -181,6 +235,84 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
await expect( saveButton ).toBeDisabled();
} );
+ test( 'submits an untouched inventory value through classic sanitization', async ( {
+ baseURL,
+ page,
+ } ) => {
+ const url = getBaseURL( baseURL );
+ await setFeatureFlag( request, url, 'settings-ui', true );
+ await setOption( request, url, 'woocommerce_manage_stock', 'yes' );
+ await setOption( request, url, 'woocommerce_hold_stock_minutes', '60' );
+ await setOption(
+ request,
+ url,
+ 'woocommerce_notify_low_stock_amount',
+ '02'
+ );
+
+ await page.goto(
+ 'wp-admin/admin.php?page=wc-settings&tab=products§ion=inventory'
+ );
+
+ const editedHoldStock = page.getByRole( 'spinbutton', {
+ name: 'Hold stock (minutes)',
+ } );
+ const preservedLowStock = page.getByRole( 'spinbutton', {
+ name: 'Low stock threshold',
+ } );
+ const lowStockFormValue = page.locator(
+ 'input[type="hidden"][name="woocommerce_notify_low_stock_amount"]'
+ );
+
+ const saveButton = page.getByRole( 'button', {
+ name: 'Save',
+ exact: true,
+ } );
+
+ await expect(
+ page.locator( '[data-wc-settings-ui="1"]' )
+ ).toBeVisible();
+ await expect( preservedLowStock ).toHaveValue( '2' );
+ await expect( lowStockFormValue ).toHaveValue( '02' );
+ await editedHoldStock.fill( '61' );
+ await editedHoldStock.blur();
+ await expect( saveButton ).toBeEnabled();
+ await expect( lowStockFormValue ).toHaveValue( '02' );
+
+ const saveRequestPromise = page.waitForRequest( ( httpRequest ) => {
+ return (
+ httpRequest.method() === 'POST' &&
+ httpRequest.url().includes( 'page=wc-settings' )
+ );
+ } );
+ await saveButton.click();
+ const saveRequest = await saveRequestPromise;
+ expect(
+ getPostedFormValue(
+ saveRequest.postData(),
+ 'woocommerce_notify_low_stock_amount'
+ )
+ ).toBe( '02' );
+
+ await expect( page.locator( 'div.updated.inline' ) ).toContainText(
+ 'Your settings have been saved.'
+ );
+ await expect( preservedLowStock ).toBeVisible();
+ await expect( preservedLowStock ).toHaveValue( '2' );
+ await expect( editedHoldStock ).toHaveValue( '61' );
+
+ const [ holdStockOption, lowStockOption ] = await Promise.all( [
+ wpCLI(
+ 'wp option get woocommerce_hold_stock_minutes --skip-plugins'
+ ),
+ wpCLI(
+ 'wp option get woocommerce_notify_low_stock_amount --skip-plugins'
+ ),
+ ] );
+ expect( holdStockOption.stdout.trim() ).toBe( '61' );
+ expect( lowStockOption.stdout.trim() ).toBe( '2' );
+ } );
+
test( 'loads a declared component registration before mounting settings', async ( {
page,
} ) => {
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
index 9791b09c3ed..cd41d3d3131 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -169,11 +169,65 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( 'registered_acme_payments_setting', $schema['groups'] );
}
+ /**
+ * @testdox A registered native page converts transitional number and datetime values before validation.
+ */
+ public function test_registered_native_page_schema_accepts_transitional_number_and_datetime_values(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+ $compatibility_notices = 0;
+ $notice_listener = static function ( $function_name ) use ( &$compatibility_notices ): void {
+ if ( SettingsUISchema::class . '::canonicalize_schema_values' === $function_name ) {
+ ++$compatibility_notices;
+ }
+ };
+ add_action( 'doing_it_wrong_run', $notice_listener );
+
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register(
+ $this->get_registered_section_with_native_settings_ui_page(
+ null,
+ null,
+ null,
+ array(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Acme number',
+ 'type' => 'number',
+ 'value' => '02',
+ 'save' => array( 'adapter' => 'form_post' ),
+ ),
+ array(
+ 'id' => 'acme_datetime',
+ 'label' => 'Acme datetime',
+ 'type' => 'datetime-local',
+ 'value' => '2026-08-03T12:30',
+ 'save' => array( 'adapter' => 'form_post' ),
+ ),
+ )
+ )
+ );
+
+ try {
+ $context = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' );
+ $schema = $context->get_schema();
+ } finally {
+ remove_action( 'doing_it_wrong_run', $notice_listener );
+ }
+
+ $this->assertFalse( $context->has_schema_failed() );
+ $this->assertSame( 1, $compatibility_notices, 'All converted native fields should be reported in one compatibility notice.' );
+ $this->assertSame( 2, $schema['groups']['native_group']['fields'][0]['value'] );
+ $this->assertArrayNotHasKey( 'initialValue', $schema['groups']['native_group']['fields'][0]['save'] );
+ $this->assertSame( '2026-08-03T12:30:00+00:00', $schema['groups']['native_group']['fields'][1]['value'] );
+ $this->assertArrayNotHasKey( 'initialValue', $schema['groups']['native_group']['fields'][1]['save'] );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
/**
* @testdox Should canonicalize option values from a native Settings UI page provider.
*/
public function test_canonicalizes_option_values_from_native_settings_ui_page(): void {
- $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_option_values' );
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
$page = $this->get_parent_page();
SettingsSectionRegistry::get_instance()->register(
@@ -187,6 +241,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
'label' => 'Tier',
'type' => 'select',
'value' => 1,
+ 'save' => array( 'adapter' => 'form_post' ),
'options' => array(
array(
'label' => 'One',
@@ -209,6 +264,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$field = $schema['groups']['native_group']['fields'][0];
$this->assertSame( '1', $field['value'], 'The selected value should follow its options to string form.' );
$this->assertSame( array( '1', '2' ), array_column( $field['options'], 'value' ), 'Scalar option values should canonicalize to strings.' );
+ $this->assertArrayNotHasKey( 'initialValue', $field['save'], 'Existing option conversion should keep using its canonical string transport.' );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
index 27a3d685f46..1705934e9e8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
@@ -12,6 +12,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Admin\Settings;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Settings;
use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUISchema;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use WC_Unit_Test_Case;
@@ -330,6 +331,59 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$this->assertSame( $stored_before, $stored_after, 'Classic fallback must preserve the raw stored option representation.' );
}
+ /**
+ * @testdox Should render classic settings without saving when value canonicalization fails.
+ */
+ public function test_invalid_value_canonicalization_uses_classic_fallback_without_saving(): void {
+ global $current_section, $current_tab, $wpdb;
+
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ $this->setExpectedIncorrectUsage( 'WC_Settings_Page::output' );
+
+ update_option( 'woocommerce_settings_ui_flag_test', '9007199254740992' );
+ $stored_before = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
+ 'woocommerce_settings_ui_flag_test'
+ )
+ );
+
+ $current_section = '';
+ $current_tab = 'settings_ui_flag_test';
+ $page = $this->get_settings_ui_test_page_with_invalid_canonical_value();
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+ $save_calls = 0;
+ $save_listener = static function ( $value ) use ( &$save_calls ) {
+ ++$save_calls;
+ return $value;
+ };
+ add_filter( 'woocommerce_admin_settings_sanitize_option', $save_listener );
+
+ try {
+ $output = $this->render_settings_view( $page );
+ } finally {
+ remove_filter( 'woocommerce_admin_settings_sanitize_option', $save_listener );
+ remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ }
+
+ $stored_after = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
+ 'woocommerce_settings_ui_flag_test'
+ )
+ );
+
+ $this->assertNull( $context->get_schema() );
+ $this->assertTrue( $context->has_schema_failed() );
+ $this->assertStringContainsString( 'outside the JavaScript safe integer range', $context->get_schema_failure_reason() );
+ $this->assertStringContainsString( 'name="woocommerce_settings_ui_flag_test"', $output );
+ $this->assertStringContainsString( 'class="woocommerce-save-button', $output );
+ $this->assertStringNotContainsString( 'data-wc-settings-ui="1"', $output );
+ $this->assertSame( 0, $save_calls, 'Rendering classic fallback must not invoke the settings save pipeline.' );
+ $this->assertSame( $stored_before, $stored_after );
+ }
+
/**
* @testdox Should fall back to classic settings when a declared script handle is not registered.
*/
@@ -552,15 +606,20 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should override a schema-provided shell header for top-level pages.
+ * @testdox Should canonicalize and override a schema from a public legacy adapter subclass.
*/
public function test_request_context_overrides_a_schema_provided_shell_header(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
$page = $this->get_settings_ui_test_page_with_visible_shell_header();
$context = SettingsUIRequestContext::for_settings_page( $page, '' );
$schema = $context->get_schema();
+ $field = $schema['groups']['default']['fields'][1];
$this->assertSame( 'hidden', $schema['shell']['header'], 'Top-level pages cannot opt into the shell header.' );
+ $this->assertSame( 2, $field['value'] );
+ $this->assertSame( '02', $field['save']['initialValue'] );
}
/**
@@ -1023,8 +1082,15 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
* @return array
*/
public function get_schema( string $section ): array {
- $schema = parent::get_schema( $section );
- $schema['shell']['header'] = 'visible';
+ $schema = parent::get_schema( $section );
+ $schema['shell']['header'] = 'visible';
+ $schema['groups']['default']['fields'][] = array(
+ 'id' => 'extended_quantity',
+ 'label' => 'Extended quantity',
+ 'type' => 'number',
+ 'value' => '02',
+ 'save' => array( 'adapter' => 'form_post' ),
+ );
return $schema;
}
@@ -1351,6 +1417,48 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
};
}
+ /**
+ * Build a settings page whose numeric value cannot cross the JavaScript boundary safely.
+ *
+ * @return \WC_Settings_Page
+ */
+ private function get_settings_ui_test_page_with_invalid_canonical_value(): \WC_Settings_Page {
+ return new class() extends \WC_Settings_Page {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'settings_ui_flag_test';
+ $this->label = 'Settings UI flag test';
+ }
+
+ /**
+ * Get the settings UI page adapter.
+ *
+ * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+ return new \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter( $this );
+ }
+
+ /**
+ * Get settings for the default section.
+ *
+ * @return array
+ */
+ protected function get_settings_for_default_section() {
+ return array(
+ array(
+ 'id' => 'woocommerce_settings_ui_flag_test',
+ 'type' => 'number',
+ 'title' => 'Settings UI flag test',
+ 'custom_attributes' => array( 'step' => 1 ),
+ ),
+ );
+ }
+ };
+ }
+
/**
* Build a settings page declaring extension script handles.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
index 5bd028c85c8..906411e3ea1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
@@ -247,8 +247,9 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
$this->assertSame(
array(
- 'adapter' => 'form_post',
- 'name' => 'woocommerce_test[nested]',
+ 'adapter' => 'form_post',
+ 'name' => 'woocommerce_test[nested]',
+ 'initialValue' => '',
),
$schema['groups']['default']['fields'][0]['save']
);
@@ -274,7 +275,8 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
$field = $schema['groups']['default']['fields'][0];
$this->assertSame( 'Read-only <strong>information</strong>alert("x").', $field['description'] );
- $this->assertSame( array( 'adapter' => 'none' ), $field['save'] );
+ $this->assertArrayHasKey( 'adapter', $field['save'] );
+ $this->assertSame( 'none', $field['save']['adapter'] );
SettingsUISchema::assert_valid_schema( $schema );
}
@@ -1015,168 +1017,1947 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
}
/**
- * @testdox It accepts Settings UI transport values without interpreting the field type.
+ * @testdox It canonicalizes visibility values with their typed controllers.
+ */
+ public function test_canonicalize_schema_values_matches_typed_visibility_values(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_fields(
+ array(
+ array(
+ 'id' => 'acme_enabled',
+ 'type' => 'checkbox',
+ 'value' => 'yes',
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_enabled_note',
+ 'type' => 'text',
+ 'value' => '',
+ 'visibility' => array(
+ 'controller' => 'acme_enabled',
+ 'value' => array( 'no', 'yes', 'maybe' ),
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_ratio',
+ 'type' => 'number',
+ 'value' => '2.5',
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_count',
+ 'type' => 'integer',
+ 'value' => '2',
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_count_note',
+ 'type' => 'text',
+ 'value' => '',
+ 'visibility' => array(
+ 'controller' => 'acme_count',
+ 'value' => array( '2', '2.5' ),
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_optional_ratio',
+ 'type' => 'number',
+ 'value' => '',
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_optional_ratio_note',
+ 'type' => 'text',
+ 'value' => '',
+ 'visibility' => array(
+ 'controller' => 'acme_optional_ratio',
+ 'value' => '',
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_ratio_note',
+ 'type' => 'text',
+ 'value' => '',
+ 'visibility' => array(
+ 'controller' => 'acme_ratio',
+ 'value' => array( '1.5', '2.5' ),
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_start',
+ 'type' => 'datetime-local',
+ 'value' => '2026-08-03T12:30Z',
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_start_note',
+ 'type' => 'text',
+ 'value' => '',
+ 'visibility' => array(
+ 'controller' => 'acme_start',
+ 'value' => '2026-08-03T12:30Z',
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_methods',
+ 'type' => 'array',
+ 'value' => array( 1, 2 ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ array(
+ 'id' => 'acme_methods_note',
+ 'type' => 'text',
+ 'value' => '',
+ 'visibility' => array(
+ 'controller' => 'acme_methods',
+ 'value' => array( array( 1, 2 ) ),
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ ),
+ )
+ )
+ );
+
+ $fields = array_column( $schema['groups']['main']['fields'], null, 'id' );
+
+ $this->assertSame( true, $fields['acme_enabled']['value'] );
+ $this->assertSame( array( false, true, 'maybe' ), $fields['acme_enabled_note']['visibility']['value'] );
+ $this->assertSame( 2.5, $fields['acme_ratio']['value'] );
+ $this->assertSame( array( 1.5, 2.5 ), $fields['acme_ratio_note']['visibility']['value'] );
+ $this->assertSame( 2, $fields['acme_count']['value'] );
+ $this->assertSame( array( 2, '2.5' ), $fields['acme_count_note']['visibility']['value'] );
+ $this->assertNull( $fields['acme_optional_ratio']['value'] );
+ $this->assertNull( $fields['acme_optional_ratio_note']['visibility']['value'] );
+ $this->assertSame( '2026-08-03T12:30:00+00:00', $fields['acme_start']['value'] );
+ $this->assertSame( '2026-08-03T12:30:00+00:00', $fields['acme_start_note']['visibility']['value'] );
+ $this->assertSame( array( '1', '2' ), $fields['acme_methods']['value'] );
+ $this->assertSame( array( array( '1', '2' ) ), $fields['acme_methods_note']['visibility']['value'] );
+ }
+
+ /**
+ * @testdox It canonicalizes legacy values, numeric bounds, and original form representations atomically.
+ */
+ public function test_from_legacy_settings_canonicalizes_typed_values_and_preserves_form_values(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_quantity',
+ 'label' => 'Quantity',
+ 'type' => 'number',
+ 'value' => '02',
+ 'custom_attributes' => array(
+ 'min' => '0',
+ 'max' => '10',
+ 'step' => '1',
+ ),
+ ),
+ )
+ );
+
+ $field = $schema['groups']['default']['fields'][0];
+
+ $this->assertSame( 'integer', $field['type'] );
+ $this->assertSame( 2, $field['value'] );
+ $this->assertSame(
+ array(
+ 'min' => 0,
+ 'max' => 10,
+ ),
+ $field['validation']
+ );
+ $this->assertSame( '02', $field['save']['initialValue'] );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It canonicalizes native numeric values without rounding unsafe integers first.
*
- * @dataProvider settings_ui_values
+ * @dataProvider canonical_numeric_values
*
- * @param string $type Field type.
- * @param mixed $value Field value.
+ * @param mixed $raw Raw schema value.
+ * @param string $type Field type.
+ * @param int|float|null $expected Expected canonical value.
*/
- public function test_assert_valid_schema_accepts_settings_ui_values_without_interpreting_field_type( string $type, $value ): void {
- $field = array(
- 'id' => 'acme_custom_field',
- 'label' => 'Acme custom field',
- 'type' => $type,
- 'value' => $value,
- 'save' => array( 'adapter' => 'form_post' ),
+ public function test_canonicalize_schema_values_handles_numeric_boundaries( $raw, string $type, $expected ): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => $type,
+ 'value' => $raw,
+ 'save' => array( 'adapter' => 'custom' ),
+ )
);
- SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
- $this->addToAssertionCount( 1 );
+ $canonical = SettingsUISchema::canonicalize_schema_values( $schema );
+
+ $this->assertSame( $expected, $canonical['groups']['main']['fields'][0]['value'] );
}
/**
- * Settings UI transport value fixtures.
+ * Canonical numeric value fixtures.
*
- * @return array<string, array{string, mixed}>
+ * @return array<string, array{mixed, string, int|float|null}>
*/
- public static function settings_ui_values(): array {
+ public static function canonical_numeric_values(): array {
return array(
- 'number string' => array( 'number', '02' ),
- 'datetime-local string' => array( 'datetime-local', '2026-08-03T12:30' ),
- 'extension string' => array( 'acme/custom', 'Acme' ),
- 'extension integer' => array( 'acme/custom', 10 ),
- 'extension float' => array( 'acme/custom', 10.5 ),
- 'extension boolean' => array( 'acme/custom', true ),
- 'extension string list' => array( 'acme/custom', array( 'one', 'two' ) ),
- 'extension null' => array( 'acme/custom', null ),
+ 'empty number' => array( '', 'number', null ),
+ 'whitespace number' => array( ' ', 'number', null ),
+ 'zero number' => array( '0', 'number', 0 ),
+ 'zero big exponent' => array( '0e17', 'number', 0 ),
+ 'signed zero exponent' => array( '-0e17', 'integer', 0 ),
+ 'bare zero fraction' => array( '.0e30', 'integer', 0 ),
+ 'zero huge exponent' => array( '0e99999999', 'integer', 0 ),
+ 'decimal number' => array( '1.25', 'number', 1.25 ),
+ 'equivalent decimal' => array( '01.2500e0', 'number', 1.25 ),
+ 'exponent number' => array( '1e3', 'number', 1000 ),
+ 'padded exponent' => array( '1e+0000007', 'integer', 10000000 ),
+ 'negative zero exp' => array( '1e-0000000', 'integer', 1 ),
+ 'safe integer maximum' => array( '9007199254740991', 'integer', 9007199254740991 ),
+ 'safe integer minimum' => array( '-9007199254740991', 'integer', -9007199254740991 ),
);
}
/**
- * @testdox It accepts choice fields with missing or empty option lists.
+ * @testdox It converts supported non-typed scalar and null values to strings.
+ *
+ * @dataProvider supported_scalar_values
+ *
+ * @param string $type Field type.
+ * @param mixed $value Raw field value.
+ * @param string $expected Expected canonical value.
*/
- public function test_assert_valid_schema_accepts_choice_fields_without_options(): void {
- $fields = array(
- array(
- 'id' => 'acme_select_without_options',
- 'label' => 'Select without options',
- 'type' => 'select',
- 'value' => '',
- 'save' => array( 'adapter' => 'form_post' ),
- ),
- array(
- 'id' => 'acme_array_with_empty_options',
- 'label' => 'Array with empty options',
- 'type' => 'array',
- 'value' => array(),
- 'options' => array(),
- 'save' => array( 'adapter' => 'form_post' ),
- ),
+ public function test_canonicalize_schema_values_converts_supported_scalar_values( string $type, $value, string $expected ): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_value',
+ 'label' => 'Value',
+ 'type' => $type,
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'none' ),
+ )
+ )
);
- SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_fields( $fields ) );
- $this->addToAssertionCount( 1 );
+ $this->assertSame( $expected, $schema['groups']['main']['fields'][0]['value'] );
+ SettingsUISchema::assert_valid_schema( $schema );
}
/**
- * @testdox It accepts scalar custom attributes without interpreting renderer semantics.
+ * @testdox It does not read a missing field type while it canonicalizes option values.
*/
- public function test_assert_valid_schema_accepts_scalar_custom_attributes_without_interpreting_renderer_semantics(): void {
- $field = array(
- 'id' => 'acme_custom_field',
- 'label' => 'Acme custom field',
- 'type' => 'acme/custom',
- 'value' => '',
- 'customAttributes' => array(
- 'min' => 'extension-defined',
- 'max' => 10,
- 'step' => 'any',
- 'data-enabled' => true,
- ),
- 'save' => array( 'adapter' => 'form_post' ),
+ public function test_canonicalize_schema_values_handles_missing_field_type(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_value',
+ 'label' => 'Value',
+ 'value' => 1,
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => 1,
+ ),
+ ),
+ )
+ )
);
- SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
- $this->addToAssertionCount( 1 );
+ $field = $schema['groups']['main']['fields'][0];
+ $this->assertArrayNotHasKey( 'type', $field );
+ $this->assertSame( '1', $field['value'] );
+ $this->assertSame( '1', $field['options'][0]['value'] );
}
/**
- * @testdox It rejects malformed schemas with a precise boundary reason.
+ * Supported scalar value fixtures.
*
- * @dataProvider invalid_schemas
+ * @return array<string, array{string, mixed, string}>
+ */
+ public static function supported_scalar_values(): array {
+ return array(
+ 'integer text' => array( 'text', 12, '12' ),
+ 'float textarea' => array( 'textarea', 1.25, '1.25' ),
+ 'boolean password' => array( 'password', false, 'false' ),
+ 'null URL' => array( 'url', null, '' ),
+ );
+ }
+
+ /**
+ * @testdox It rejects decimal values that change during numeric canonicalization.
*
- * @param array $schema Invalid schema.
- * @param string $reason Expected exception message.
+ * @dataProvider lossy_decimal_values
+ *
+ * @param string $value Lossy decimal value.
*/
- public function test_assert_valid_schema_rejects_malformed_schemas( array $schema, string $reason ): void {
+ public function test_canonicalize_schema_values_rejects_lossy_decimal_values( string $value ): void {
$this->expectException( \InvalidArgumentException::class );
- $this->expectExceptionMessage( $reason );
+ $this->expectExceptionMessage( 'cannot be represented as a finite number without loss' );
- SettingsUISchema::assert_valid_schema( $schema );
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ )
+ );
}
/**
- * Invalid schema fixtures.
+ * Lossy decimal value fixtures.
*
- * @return array<string, array{array, string}>
+ * @return array<string, array{string}>
*/
- public static function invalid_schemas(): array {
- $valid = self::get_valid_schema_for_validation();
+ public static function lossy_decimal_values(): array {
+ return array(
+ 'rounded fraction' => array( '0.10000000000000001' ),
+ 'rounded integer' => array( '1.0000000000000000001' ),
+ 'underflow' => array( '1e-324' ),
+ );
+ }
- $empty_type = $valid;
- $empty_type['groups']['main']['fields'][0]['type'] = '';
+ /**
+ * @testdox It rejects integral numeric values outside JavaScript's safe range before conversion.
+ *
+ * @dataProvider unsafe_integral_values
+ *
+ * @param string $value Unsafe integral value.
+ */
+ public function test_canonicalize_schema_values_rejects_unsafe_integral_values( string $value ): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'outside the JavaScript safe integer range' );
- $duplicate_id = $valid;
- $duplicate_id['groups']['main']['fields'][] = $duplicate_id['groups']['main']['fields'][0];
- $group_field_collision = $valid;
- $group_field_collision['groups']['main']['fields'][0]['id'] = 'main';
- $empty_schema_id = $valid;
- $empty_schema_id['id'] = '';
- $malformed_group = $valid;
- $malformed_group['groups']['main']['fields'] = 'invalid';
- $invalid_options = $valid;
- $invalid_options['groups']['main']['fields'][0]['type'] = 'select';
- $invalid_options['groups']['main']['fields'][0]['options'] = array( 'one' => 'One' );
- $null_options = $valid;
- $null_options['groups']['main']['fields'][0]['type'] = 'select';
- $null_options['groups']['main']['fields'][0]['options'] = null;
- $invalid_option = $valid;
- $invalid_option['groups']['main']['fields'][0]['type'] = 'select';
- $invalid_option['groups']['main']['fields'][0]['options'] = array(
- array(
- 'label' => 'One',
- 'value' => 1,
- ),
- );
- $invalid_component = $valid;
- $invalid_component['groups']['main']['fields'][0]['component'] = '';
- $invalid_field_save = $valid;
- $invalid_field_save['groups']['main']['fields'][0]['save'] = array( 'adapter' => 'custom' );
- $invalid_visibility = $valid;
- $invalid_visibility['groups']['main']['fields'][0]['visibility'] = array( 'controller' => 'missing' );
- $invalid_field_value = $valid;
- $invalid_field_value['groups']['main']['fields'][0]['value'] = array( 'tier' => 1 );
- $invalid_custom_attributes = $valid;
- $invalid_custom_attributes['groups']['main']['fields'][0]['customAttributes'] = 'invalid';
- $invalid_custom_attribute_value = $valid;
- $invalid_custom_attribute_value['groups']['main']['fields'][0]['customAttributes'] = array( 'data-values' => array() );
- $invalid_custom_attribute_float = $valid;
- $invalid_custom_attribute_float['groups']['main']['fields'][0]['customAttributes'] = array( 'data-value' => INF );
- $invalid_info = $valid;
- $invalid_info['groups']['main']['fields'][0]['type'] = 'info';
- $invalid_shell = $valid;
- $invalid_shell['shell']['navigation'] = array(
- array(
- 'id' => 'general',
- 'label' => 'General',
- ),
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_integer',
+ 'label' => 'Integer',
+ 'type' => 'integer',
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ )
);
- $invalid_breadcrumb = $valid;
- $invalid_breadcrumb['shell']['breadcrumbs'] = array( array( 'label' => 1 ) );
- $invalid_badge = $valid;
- $invalid_badge['shell']['badges'] = array(
- array(
+ }
+
+ /**
+ * Unsafe integral value fixtures.
+ *
+ * @return array<string, array{string}>
+ */
+ public static function unsafe_integral_values(): array {
+ return array(
+ 'above maximum' => array( '9007199254740992' ),
+ 'below minimum' => array( '-9007199254740992' ),
+ 'exponent' => array( '9.007199254740992e15' ),
+ );
+ }
+
+ /**
+ * @testdox It rejects unsafe integral bounds before converting them to floats.
+ *
+ * @dataProvider unsafe_integral_bounds
+ *
+ * @param string $bound Bound name.
+ * @param string $value Unsafe bound value.
+ */
+ public function test_canonicalize_schema_values_rejects_unsafe_integral_bounds( string $bound, string $value ): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( $bound . ' is outside the JavaScript safe integer range' );
+
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => 2,
+ 'customAttributes' => array( $bound => $value ),
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ )
+ );
+ }
+
+ /**
+ * Unsafe integral bound fixtures.
+ *
+ * @return array<string, array{string, string}>
+ */
+ public static function unsafe_integral_bounds(): array {
+ return array(
+ 'above maximum' => array( 'max', '9007199254740992' ),
+ 'below minimum' => array( 'min', '-9007199254740992' ),
+ );
+ }
+
+ /**
+ * @testdox It promotes step-one numbers only when the stored value and bounds are all integral.
+ *
+ * @dataProvider integer_inference_values
+ *
+ * @param array $field Field definition.
+ * @param string $expected_type Expected canonical type.
+ */
+ public function test_canonicalize_schema_values_infers_integer_from_step_base( array $field, string $expected_type ): void {
+ $field += array(
+ 'id' => 'acme_number',
+ 'title' => 'Number',
+ 'type' => 'number',
+ );
+
+ $schema = SettingsUISchema::from_legacy_settings( 'acme', '', 'Acme', array( $field ), 'custom' );
+
+ $this->assertSame( $expected_type, $schema['groups']['default']['fields'][0]['type'] );
+ }
+
+ /**
+ * @testdox It keeps a step-one field holding a decimal value as a number instead of collapsing the schema.
+ */
+ public function test_canonicalize_schema_values_keeps_decimal_step_one_value_as_number(): void {
+ $field = array(
+ 'id' => 'acme_number',
+ 'title' => 'Number',
+ 'type' => 'number',
+ 'value' => '2.5',
+ 'custom_attributes' => array(
+ 'step' => '1',
+ 'min' => '0',
+ ),
+ );
+
+ // A decimal stored under a step=1/min=0 control must not be promoted to
+ // integer: integer canonicalization would throw and fail the section
+ // closed. It stays a number and its value is preserved.
+ $schema = SettingsUISchema::from_legacy_settings( 'acme', '', 'Acme', array( $field ), 'custom' );
+ $result = $schema['groups']['default']['fields'][0];
+
+ $this->assertSame( 'number', $result['type'] );
+ $this->assertSame( 2.5, $result['value'] );
+ }
+
+ /**
+ * Integer inference fixtures.
+ *
+ * @return array<string, array{array, string}>
+ */
+ public static function integer_inference_values(): array {
+ return array(
+ 'min takes precedence' => array(
+ array(
+ 'value' => '2',
+ 'custom_attributes' => array(
+ 'step' => '1',
+ 'min' => '0.5',
+ ),
+ ),
+ 'number',
+ ),
+ 'integral current value' => array(
+ array(
+ 'value' => '2',
+ 'custom_attributes' => array( 'step' => 1 ),
+ ),
+ 'integer',
+ ),
+ 'empty uses zero step base' => array(
+ array(
+ 'value' => '',
+ 'custom_attributes' => array( 'step' => 1 ),
+ ),
+ 'integer',
+ ),
+ 'non-unit step stays number' => array(
+ array(
+ 'value' => '2',
+ 'custom_attributes' => array( 'step' => '0.5' ),
+ ),
+ 'number',
+ ),
+ 'near-one step stays number' => array(
+ array(
+ 'value' => '2',
+ 'custom_attributes' => array( 'step' => '1.0000000000000000001' ),
+ ),
+ 'number',
+ ),
+ 'decimal value with integral min stays number' => array(
+ array(
+ 'value' => '2.5',
+ 'custom_attributes' => array(
+ 'step' => '1',
+ 'min' => '0',
+ ),
+ ),
+ 'number',
+ ),
+ 'integral value with integral min promotes' => array(
+ array(
+ 'value' => '2',
+ 'custom_attributes' => array(
+ 'step' => '1',
+ 'min' => '0',
+ ),
+ ),
+ 'integer',
+ ),
+ 'decimal max stays number' => array(
+ array(
+ 'value' => '2',
+ 'custom_attributes' => array(
+ 'step' => '1',
+ 'max' => '10.5',
+ ),
+ ),
+ 'number',
+ ),
+ );
+ }
+
+ /**
+ * @testdox It does not require form representation metadata for a page-level custom save strategy.
+ */
+ public function test_canonicalize_schema_values_skips_initial_value_for_custom_page_save(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_choices',
+ 'label' => 'Choices',
+ 'type' => 'array',
+ 'value' => array( 1 ),
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => '1',
+ ),
+ ),
+ )
+ );
+ $schema['save'] = array(
+ 'adapter' => 'custom',
+ 'handler' => 'acme/save',
+ );
+
+ $canonical = SettingsUISchema::canonicalize_schema_values( $schema );
+ $field = $canonical['groups']['main']['fields'][0];
+
+ $this->assertSame( array( '1' ), $field['value'] );
+ $this->assertArrayNotHasKey( 'save', $field );
+ SettingsUISchema::assert_valid_schema( $canonical );
+ }
+
+ /**
+ * @testdox It converts store-local datetimes to timezone-qualified ISO values while preserving form precision.
+ */
+ public function test_from_legacy_settings_canonicalizes_local_datetime(): void {
+ $original_timezone = get_option( 'timezone_string' );
+ update_option( 'timezone_string', 'America/New_York' );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_start',
+ 'label' => 'Starts',
+ 'type' => 'datetime-local',
+ 'value' => '2026-11-01T01:30',
+ ),
+ )
+ );
+ } finally {
+ update_option( 'timezone_string', $original_timezone );
+ }
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( '2026-11-01T01:30:00-04:00', $field['value'], 'PHP deterministically chooses the first occurrence of New York\'s repeated hour.' );
+ $this->assertSame( '2026-11-01T01:30', $field['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It reads one-level legacy form names and captures the exact nested member.
+ */
+ public function test_from_legacy_settings_reads_nested_form_option_once(): void {
+ update_option(
+ 'acme_settings',
+ array(
+ 'quantity' => '02',
+ 'other' => 'keep',
+ )
+ );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_quantity',
+ 'field_name' => 'acme_settings[quantity]',
+ 'label' => 'Quantity',
+ 'type' => 'number',
+ 'custom_attributes' => array( 'step' => 1 ),
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_settings' );
+ }
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( 2, $field['value'] );
+ $this->assertSame( '02', $field['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It falls back to the field default when a nested option parent is not an array.
+ */
+ public function test_from_legacy_settings_falls_back_for_non_array_nested_option_parent(): void {
+ update_option( 'acme_settings', 'not-an-array' );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_quantity',
+ 'field_name' => 'acme_settings[quantity]',
+ 'label' => 'Quantity',
+ 'type' => 'text',
+ 'default' => 'fallback',
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_settings' );
+ }
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( 'fallback', $field['value'] );
+ $this->assertSame( 'fallback', $field['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It uses the classic settings reader's unslash behavior.
+ */
+ public function test_from_legacy_settings_unslashes_nested_option_values(): void {
+ update_option( 'acme_settings', array( 'copy' => "It\\'s ready" ) );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_copy',
+ 'field_name' => 'acme_settings[copy]',
+ 'label' => 'Copy',
+ 'type' => 'text',
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_settings' );
+ }
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( 'It\'s ready', $field['value'] );
+ $this->assertSame( 'It\'s ready', $field['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It falls back to the field ID when field_name is an empty scalar.
+ */
+ public function test_from_legacy_settings_uses_id_for_empty_field_name(): void {
+ update_option( 'acme_quantity', 'from-id' );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_quantity',
+ 'field_name' => '',
+ 'label' => 'Quantity',
+ 'type' => 'text',
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_quantity' );
+ }
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( 'acme_quantity', $field['save']['name'] );
+ $this->assertSame( 'from-id', $field['value'] );
+ $this->assertSame( 'from-id', $field['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It reads a legacy array option whose configured form name includes the list suffix.
+ */
+ public function test_from_legacy_settings_reads_array_option_with_list_suffix(): void {
+ update_option(
+ 'acme_settings',
+ array(
+ 'methods' => array( 'card', 'link' ),
+ )
+ );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_methods',
+ 'field_name' => 'acme_settings[methods][]',
+ 'label' => 'Methods',
+ 'type' => 'multiselect',
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_settings' );
+ }
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( array( 'card', 'link' ), $field['value'] );
+ $this->assertSame( 'acme_settings[methods][]', $field['save']['name'] );
+ $this->assertSame( array( 'card', 'link' ), $field['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It rejects deep legacy form names before the Settings UI mounts.
+ */
+ public function test_from_legacy_settings_rejects_deep_form_option_names(): void {
+ update_option( 'acme_quantity', '02' );
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'has an unsupported name' );
+
+ try {
+ SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_quantity',
+ 'field_name' => 'acme_settings[group][quantity]',
+ 'label' => 'Quantity',
+ 'type' => 'number',
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_quantity' );
+ }
+ }
+
+ /**
+ * @testdox It rejects deep legacy array names before the Settings UI mounts.
+ */
+ public function test_from_legacy_settings_rejects_deep_array_form_names(): void {
+ update_option( 'acme_methods', array( 'card', 'link' ) );
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'has an unsupported name' );
+
+ try {
+ SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_methods',
+ 'field_name' => 'acme_settings[group][methods][]',
+ 'label' => 'Methods',
+ 'type' => 'multiselect',
+ ),
+ )
+ );
+ } finally {
+ delete_option( 'acme_methods' );
+ }
+ }
+
+ /**
+ * @testdox It requires an explicit original form value when native compatibility conversion cannot preserve one.
+ */
+ public function test_canonicalize_schema_values_rejects_ambiguous_native_form_value(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'must define save.initialValue' );
+
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_choices',
+ 'label' => 'Choices',
+ 'type' => 'array',
+ 'value' => array( 1 ),
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => '1',
+ ),
+ ),
+ 'save' => array( 'adapter' => 'form_post' ),
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox It rejects an original form value that does not match the canonical current value.
+ */
+ public function test_assert_valid_schema_rejects_mismatched_initial_form_value(): void {
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_quantity',
+ 'label' => 'Quantity',
+ 'type' => 'number',
+ 'value' => 2,
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_quantity',
+ 'initialValue' => '03',
+ ),
+ )
+ )
+ );
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'save.initialValue cannot be replayed safely' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It rejects an initial form value when a typed field has no current value.
+ */
+ public function test_assert_valid_schema_rejects_initial_form_value_without_current_value(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_quantity',
+ 'label' => 'Quantity',
+ 'type' => 'number',
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_quantity',
+ 'initialValue' => '',
+ ),
+ )
+ );
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'save.initialValue cannot be replayed safely' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It rejects an initial form value that disagrees with an untyped string field.
+ */
+ public function test_assert_valid_schema_rejects_mismatched_initial_form_value_for_string_field(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_label',
+ 'label' => 'Label',
+ 'type' => 'text',
+ 'value' => 'shown',
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_label',
+ 'initialValue' => 'stored',
+ ),
+ )
+ );
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'save.initialValue cannot be replayed safely' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It keeps the canonical string transport for existing option-provider conversion.
+ *
+ * @dataProvider compatible_option_provider_values
+ *
+ * @param string $type Field type.
+ * @param bool|int|float $value Provider value.
+ * @param string $expected Canonical string value.
+ */
+ public function test_canonicalize_schema_values_keeps_option_provider_string_transport( string $type, $value, string $expected ): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_option',
+ 'label' => 'Option',
+ 'type' => $type,
+ 'value' => $value,
+ 'options' => array(
+ array(
+ 'label' => 'Current',
+ 'value' => $value,
+ ),
+ ),
+ 'save' => array( 'adapter' => 'form_post' ),
+ )
+ )
+ );
+
+ $field = $schema['groups']['main']['fields'][0];
+ $this->assertSame( $expected, $field['value'] );
+ $this->assertSame( $expected, $field['options'][0]['value'] );
+ $this->assertArrayNotHasKey( 'initialValue', $field['save'] );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * Existing option-provider conversion fixtures.
+ *
+ * @return array<string, array{string, bool|int|float, string}>
+ */
+ public static function compatible_option_provider_values(): array {
+ return array(
+ 'select integer' => array( 'select', 1, '1' ),
+ 'radio boolean' => array( 'radio', true, 'true' ),
+ 'extension boolean' => array( 'acme/custom', false, 'false' ),
+ );
+ }
+
+ /**
+ * @testdox It rejects conflicting legacy and canonical numeric bounds.
+ */
+ public function test_canonicalize_schema_values_rejects_conflicting_bounds(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'min disagrees between customAttributes and validation' );
+
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => 2,
+ 'customAttributes' => array( 'min' => '1' ),
+ 'validation' => array( 'min' => 2 ),
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox It uses PHP's warning-free shifted instant for a store-local DST gap.
+ *
+ * @dataProvider dst_gap_local_datetime_values
+ *
+ * @param string $value Store-local datetime in New York's spring-forward gap.
+ */
+ public function test_canonicalize_schema_values_accepts_dst_gap_local_datetime( string $value ): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $original_timezone = get_option( 'timezone_string' );
+ update_option( 'timezone_string', 'America/New_York' );
+
+ try {
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_start',
+ 'label' => 'Starts',
+ 'type' => 'datetime-local',
+ 'value' => $value,
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_start',
+ ),
+ )
+ )
+ );
+ } finally {
+ update_option( 'timezone_string', $original_timezone );
+ }
+
+ $field = $schema['groups']['main']['fields'][0];
+ $this->assertSame( '2026-03-08T03:30:00-04:00', $field['value'] );
+ }
+
+ /**
+ * Store-local datetimes that fall in America/New_York's 2026 spring-forward gap.
+ *
+ * @return array<string, array{string}>
+ */
+ public static function dst_gap_local_datetime_values(): array {
+ return array(
+ 'without seconds' => array( '2026-03-08T02:30' ),
+ 'with seconds' => array( '2026-03-08T02:30:00' ),
+ );
+ }
+
+ /**
+ * @testdox It accepts local, UTC, and signed-offset datetime grammar with optional seconds.
+ *
+ * @dataProvider valid_datetime_grammar_values
+ *
+ * @param string $value Candidate datetime value.
+ * @param string $expected Expected canonical datetime.
+ * @param bool $expects_notice Whether compatibility conversion is expected.
+ */
+ public function test_canonicalize_schema_values_accepts_datetime_grammar_boundaries( string $value, string $expected, bool $expects_notice ): void {
+ if ( $expects_notice ) {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+ }
+
+ $original_timezone = get_option( 'timezone_string' );
+ update_option( 'timezone_string', 'UTC' );
+
+ try {
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_start',
+ 'label' => 'Starts',
+ 'type' => 'datetime-local',
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ )
+ );
+
+ $this->assertSame( $expected, $schema['groups']['main']['fields'][0]['value'] );
+ } finally {
+ update_option( 'timezone_string', $original_timezone );
+ }
+ }
+
+ /**
+ * Valid datetime grammar fixtures.
+ *
+ * @return array<string, array{string, string, bool}>
+ */
+ public static function valid_datetime_grammar_values(): array {
+ return array(
+ 'local without seconds' => array( '2026-08-03T12:30', '2026-08-03T12:30:00+00:00', true ),
+ 'local with seconds' => array( '2026-08-03T12:30:45', '2026-08-03T12:30:45+00:00', true ),
+ 'UTC Z without seconds' => array( '2026-08-03T12:30Z', '2026-08-03T12:30:00+00:00', true ),
+ 'UTC Z with seconds' => array( '2026-08-03T12:30:45Z', '2026-08-03T12:30:45+00:00', true ),
+ 'positive offset without seconds' => array( '2026-08-03T12:30+02:30', '2026-08-03T12:30:00+02:30', true ),
+ 'negative offset with seconds' => array( '2026-08-03T12:30:45-04:00', '2026-08-03T12:30:45-04:00', false ),
+ );
+ }
+
+ /**
+ * @testdox It rejects invalid dates and malformed datetime offsets.
+ *
+ * @dataProvider invalid_datetime_grammar_values
+ *
+ * @param string $value Candidate datetime value.
+ */
+ public function test_canonicalize_schema_values_rejects_invalid_datetime_grammar( string $value ): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'datetime value is malformed' );
+
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_start',
+ 'label' => 'Starts',
+ 'type' => 'datetime-local',
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ )
+ );
+ }
+
+ /**
+ * Invalid datetime grammar fixtures.
+ *
+ * @return array<string, array{string}>
+ */
+ public static function invalid_datetime_grammar_values(): array {
+ return array(
+ 'invalid local date' => array( '2026-02-30T12:00' ),
+ 'invalid qualified date' => array( '2026-02-30T12:00Z' ),
+ 'short offset hour' => array( '2026-08-03T12:30+2:00' ),
+ 'short offset minute' => array( '2026-08-03T12:30+02:0' ),
+ 'compact offset' => array( '2026-08-03T12:30+0200' ),
+ 'offset without minutes' => array( '2026-08-03T12:30+02' ),
+ 'offset +24 hours' => array( '2026-08-03T12:30+24:00' ),
+ 'offset -24 hours' => array( '2026-08-03T12:30-24:00' ),
+ 'offset +60 minutes' => array( '2026-08-03T12:30+02:60' ),
+ 'offset -99 minutes' => array( '2026-08-03T12:30-02:99' ),
+ );
+ }
+
+ /**
+ * @testdox It leaves fully canonical native typed values unchanged without a compatibility notice.
+ */
+ public function test_canonicalize_schema_values_leaves_canonical_native_values_unchanged(): void {
+ $schema = $this->get_native_schema_with_fields(
+ array(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => 2,
+ 'customAttributes' => array( 'step' => 1 ),
+ 'save' => array( 'adapter' => 'custom' ),
+ ),
+ array(
+ 'id' => 'acme_integer',
+ 'label' => 'Integer',
+ 'type' => 'integer',
+ 'value' => 2,
+ 'save' => array( 'adapter' => 'custom' ),
+ ),
+ array(
+ 'id' => 'acme_start',
+ 'label' => 'Starts',
+ 'type' => 'datetime-local',
+ 'value' => '2026-08-03T12:30:00+00:00',
+ 'save' => array( 'adapter' => 'custom' ),
+ ),
+ )
+ );
+
+ $this->assertSame( $schema, SettingsUISchema::canonicalize_schema_values( $schema ) );
+ }
+
+ /**
+ * @testdox It requires checkbox form representations to match the classic sanitizer meaning.
+ */
+ public function test_canonicalize_schema_values_rejects_incompatible_checkbox_form_value(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'must define save.initialValue' );
+
+ SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_enabled',
+ 'label' => 'Enabled',
+ 'type' => 'checkbox',
+ 'value' => 'true',
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_enabled',
+ ),
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox It mirrors canonical native validation without reporting a compatibility conversion.
+ */
+ public function test_canonicalize_schema_values_silently_mirrors_canonical_validation(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => 1.5,
+ 'validation' => array(
+ 'min' => 0.5,
+ 'max' => 2.5,
+ ),
+ 'save' => array( 'adapter' => 'custom' ),
+ )
+ );
+
+ $canonical = SettingsUISchema::canonicalize_schema_values( $schema );
+ $field = $canonical['groups']['main']['fields'][0];
+
+ $this->assertSame( $field['validation'], $field['customAttributes'] );
+ }
+
+ /**
+ * @testdox It reports and mirrors native legacy numeric custom attributes into validation metadata.
+ */
+ public function test_canonicalize_schema_values_reports_and_mirrors_legacy_numeric_attributes(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => 1.5,
+ 'customAttributes' => array(
+ 'min' => '0.5',
+ 'max' => '2.5',
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ )
+ );
+
+ $canonical = SettingsUISchema::canonicalize_schema_values( $schema );
+ $field = $canonical['groups']['main']['fields'][0];
+ $bounds = array(
+ 'min' => 0.5,
+ 'max' => 2.5,
+ );
+
+ $this->assertSame( $bounds, $field['customAttributes'] );
+ $this->assertSame( $bounds, $field['validation'] );
+ SettingsUISchema::assert_valid_schema( $canonical );
+ }
+
+ /**
+ * @testdox It treats empty numeric min and max attributes as absent.
+ */
+ public function test_canonicalize_schema_values_omits_empty_numeric_bounds(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_number',
+ 'label' => 'Number',
+ 'type' => 'number',
+ 'value' => 1.5,
+ 'customAttributes' => array(
+ 'min' => '',
+ 'max' => ' ',
+ 'step' => 'any',
+ ),
+ 'save' => array( 'adapter' => 'none' ),
+ )
+ );
+
+ $canonical = SettingsUISchema::canonicalize_schema_values( $schema );
+ $field = $canonical['groups']['main']['fields'][0];
+
+ $this->assertSame( array( 'step' => 'any' ), $field['customAttributes'] );
+ $this->assertArrayNotHasKey( 'validation', $field );
+ SettingsUISchema::assert_valid_schema( $canonical );
+ }
+
+ /**
+ * @testdox It does not read option-backed values for non-saving legacy fields.
+ */
+ public function test_from_legacy_settings_does_not_read_options_for_non_saving_fields(): void {
+ $option_reads = 0;
+ $listener = static function ( $fallback_value ) use ( &$option_reads ) {
+ ++$option_reads;
+ return $fallback_value;
+ };
+ add_filter( 'default_option_acme_external', $listener );
+
+ try {
+ SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_external',
+ 'label' => 'External',
+ 'type' => 'text',
+ 'is_option' => false,
+ ),
+ )
+ );
+ SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_external',
+ 'label' => 'External',
+ 'type' => 'text',
+ ),
+ ),
+ 'custom'
+ );
+ } finally {
+ remove_filter( 'default_option_acme_external', $listener );
+ }
+
+ $this->assertSame( 0, $option_reads );
+ }
+
+ /**
+ * @testdox It preserves an unchanged numeric form value through the classic save pipeline.
+ */
+ public function test_schema_post_preserves_unchanged_numeric_form_value(): void {
+ $settings = $this->get_numeric_save_settings();
+ update_option( 'acme_quantity', '02' );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings( 'acme', '', 'Acme', $settings );
+ $post = $this->get_schema_post_data( $schema );
+ $captured = $this->save_fields_and_capture( $settings, $post, 'acme_quantity' );
+
+ $this->assertSame( array( 'acme_quantity' => '02' ), $post );
+ $this->assertSame( '02', $captured['global']['raw'] );
+ $this->assertSame( '02', $captured['global']['sanitized'] );
+ $this->assertSame( '02', $captured['specific']['raw'] );
+ $this->assertSame( '02', $captured['specific']['sanitized'] );
+ $this->assertSame( '02', get_option( 'acme_quantity' ) );
+ $this->assertSame( '02', $this->get_raw_option_value( 'acme_quantity' ) );
+ } finally {
+ delete_option( 'acme_quantity' );
+ }
+ }
+
+ /**
+ * @testdox It sends edited numeric state through the existing classic sanitizer.
+ */
+ public function test_schema_post_saves_edited_numeric_value_canonically(): void {
+ $settings = $this->get_numeric_save_settings();
+ update_option( 'acme_quantity', '01' );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings( 'acme', '', 'Acme', $settings );
+ $post = $this->get_schema_post_data( $schema, array( 'acme_quantity' => '2' ) );
+ $captured = $this->save_fields_and_capture( $settings, $post, 'acme_quantity' );
+
+ $this->assertSame( array( 'acme_quantity' => '2' ), $post );
+ $this->assertSame( '2', $captured['global']['raw'] );
+ $this->assertSame( '2', $captured['global']['sanitized'] );
+ $this->assertSame( '2', $captured['specific']['raw'] );
+ $this->assertSame( '2', $captured['specific']['sanitized'] );
+ $this->assertSame( '2', get_option( 'acme_quantity' ) );
+ $this->assertSame( '2', $this->get_raw_option_value( 'acme_quantity' ) );
+ } finally {
+ delete_option( 'acme_quantity' );
+ }
+ }
+
+ /**
+ * @testdox It omits an unchanged empty legacy multiselect like the classic form.
+ */
+ public function test_schema_post_omits_unchanged_empty_legacy_multiselect(): void {
+ $settings = array(
+ array(
+ 'id' => 'acme_methods',
+ 'title' => 'Methods',
+ 'type' => 'multiselect',
+ 'options' => array( 'card' => 'Card' ),
+ ),
+ );
+ update_option( 'acme_methods', '' );
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings( 'acme', '', 'Acme', $settings );
+ $field = $schema['groups']['default']['fields'][0];
+ $post = $this->get_schema_post_data( $schema );
+ $post['save'] = 'Save changes';
+
+ $this->assertSame( array(), $field['save']['initialValue'] );
+ $this->assertArrayNotHasKey( 'acme_methods', $post );
+
+ $captured = $this->save_fields_and_capture( $settings, $post, 'acme_methods' );
+ $this->assertNull( $captured['global']['raw'] );
+ $this->assertSame( array(), $captured['global']['sanitized'] );
+ $this->assertNull( $captured['specific']['raw'] );
+ $this->assertSame( array(), $captured['specific']['sanitized'] );
+ } finally {
+ delete_option( 'acme_methods' );
+ }
+ }
+
+ /**
+ * @testdox It preserves a native checkbox through the classic save pipeline with a compatible original value.
+ */
+ public function test_schema_post_preserves_native_checkbox_form_value(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $settings = array(
+ array(
+ 'id' => 'acme_enabled',
+ 'title' => 'Enabled',
+ 'type' => 'checkbox',
+ ),
+ );
+ update_option( 'acme_enabled', 'yes' );
+
+ try {
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_enabled',
+ 'label' => 'Enabled',
+ 'type' => 'checkbox',
+ 'value' => 'true',
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_enabled',
+ 'initialValue' => 'yes',
+ ),
+ )
+ )
+ );
+ SettingsUISchema::assert_valid_schema( $schema );
+ $post = $this->get_schema_post_data( $schema );
+ $captured = $this->save_fields_and_capture( $settings, $post, 'acme_enabled' );
+
+ $this->assertSame( array( 'acme_enabled' => 'yes' ), $post );
+ $this->assertSame( 'yes', $captured['global']['raw'] );
+ $this->assertSame( 'yes', $captured['specific']['sanitized'] );
+ $this->assertSame( 'yes', get_option( 'acme_enabled' ) );
+ $this->assertSame( 'yes', $this->get_raw_option_value( 'acme_enabled' ) );
+ } finally {
+ delete_option( 'acme_enabled' );
+ }
+ }
+
+ /**
+ * @testdox It rejects form-post names that the hidden-input serializer cannot represent.
+ */
+ public function test_assert_valid_schema_rejects_unsupported_form_post_names(): void {
+ $schema = self::get_valid_schema_for_validation();
+ $schema['groups']['main']['fields'][0]['save']['name'] = 'settings[group][quantity';
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'is not a supported form-post field name' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It rejects list initial values for scalar fields before form serialization.
+ */
+ public function test_assert_valid_schema_rejects_list_initial_value_for_scalar_field(): void {
+ $schema = self::get_valid_schema_for_validation();
+ $field = &$schema['groups']['main']['fields'][0];
+ $field['type'] = 'number';
+ $field['value'] = 2;
+ $field['save']['initialValue'] = array( '01', '02' );
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'save.initialValue cannot be replayed safely through classic form-post semantics' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It accepts the case-insensitive any keyword for number steps.
+ */
+ public function test_assert_valid_schema_accepts_case_insensitive_any_number_step(): void {
+ $schema = self::get_valid_schema_for_validation();
+ $field = &$schema['groups']['main']['fields'][0];
+ $field['type'] = 'number';
+ $field['value'] = 2;
+ $field['customAttributes'] = array( 'step' => 'AnY' );
+
+ SettingsUISchema::assert_valid_schema( $schema );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * @testdox It rejects non-positive number steps.
+ *
+ * @dataProvider invalid_number_steps
+ *
+ * @param int|float|string $step Invalid number step.
+ */
+ public function test_assert_valid_schema_rejects_invalid_number_steps( $step ): void {
+ $schema = self::get_valid_schema_for_validation();
+ $field = &$schema['groups']['main']['fields'][0];
+ $field['type'] = 'number';
+ $field['value'] = 2;
+ $field['customAttributes'] = array( 'step' => $step );
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'custom attribute "step" must be a positive finite number or "any"' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * Invalid number step fixtures.
+ *
+ * @return array<string, array{int|float|string}>
+ */
+ public static function invalid_number_steps(): array {
+ return array(
+ 'integer zero' => array( 0 ),
+ 'decimal zero' => array( 0.0 ),
+ 'negative number' => array( -0.5 ),
+ 'zero string' => array( '0' ),
+ );
+ }
+
+ /**
+ * @testdox It rejects integer steps that cannot preserve integer values.
+ *
+ * @dataProvider invalid_integer_steps
+ *
+ * @param int|float|string $step Invalid integer step.
+ */
+ public function test_assert_valid_schema_rejects_invalid_integer_steps( $step ): void {
+ $schema = self::get_valid_schema_for_validation();
+ $field = &$schema['groups']['main']['fields'][0];
+ $field['type'] = 'integer';
+ $field['value'] = 2;
+ $field['customAttributes'] = array( 'step' => $step );
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'custom attribute "step" must be a positive integer' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * Invalid integer step fixtures.
+ *
+ * @return array<string, array{int|float|string}>
+ */
+ public static function invalid_integer_steps(): array {
+ return array(
+ 'fractional' => array( 0.5 ),
+ 'zero' => array( 0 ),
+ 'negative' => array( -1 ),
+ 'any' => array( 'any' ),
+ );
+ }
+
+ /**
+ * @testdox It preserves classic POST shapes for nested, checkbox, array, and datetime fields.
+ */
+ public function test_schema_post_matches_classic_shapes_for_typed_fields(): void {
+ $original_timezone = get_option( 'timezone_string' );
+ $settings = array(
+ array(
+ 'id' => 'acme_quantity',
+ 'field_name' => 'acme_settings[quantity]',
+ 'title' => 'Quantity',
+ 'type' => 'number',
+ 'custom_attributes' => array( 'step' => 1 ),
+ ),
+ array(
+ 'id' => 'acme_enabled',
+ 'title' => 'Enabled',
+ 'type' => 'checkbox',
+ ),
+ array(
+ 'id' => 'acme_methods',
+ 'field_name' => 'acme_settings[methods][]',
+ 'title' => 'Methods',
+ 'type' => 'multiselect',
+ 'options' => array(
+ 'card' => 'Card',
+ 'link' => 'Link',
+ ),
+ ),
+ array(
+ 'id' => 'acme_start',
+ 'title' => 'Starts',
+ 'type' => 'datetime-local',
+ ),
+ );
+ $option_names = array( 'acme_settings', 'acme_enabled', 'acme_start' );
+
+ update_option(
+ 'acme_settings',
+ array(
+ 'quantity' => '02',
+ 'methods' => array( 'card' ),
+ 'other' => 'keep',
+ )
+ );
+ update_option( 'acme_enabled', 'yes' );
+ update_option( 'acme_start', '2026-08-03T12:30' );
+ update_option( 'timezone_string', 'America/New_York' );
+
+ $captured = array();
+ $listener = static function ( $value, $option, $raw_value ) use ( &$captured ) {
+ $captured[ $option['id'] ] = $raw_value;
+ return $value;
+ };
+ add_filter( 'woocommerce_admin_settings_sanitize_option', $listener, 10, 3 );
+
+ try {
+ include_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php';
+ $schema = SettingsUISchema::from_legacy_settings( 'acme', '', 'Acme', $settings );
+ $post = $this->get_schema_post_data(
+ $schema,
+ array(
+ 'acme_quantity' => '3',
+ 'acme_enabled' => 'no',
+ 'acme_methods' => array( 'card', 'link' ),
+ 'acme_start' => '2026-08-03T13:45:00',
+ )
+ );
+
+ $this->assertTrue( \WC_Admin_Settings::save_fields( $settings, $post ) );
+ $this->clear_option_caches( $option_names );
+
+ $this->assertSame(
+ array(
+ 'acme_settings' => array(
+ 'quantity' => '3',
+ 'methods' => array( 'card', 'link' ),
+ ),
+ 'acme_enabled' => 'no',
+ 'acme_start' => '2026-08-03T13:45:00',
+ ),
+ $post
+ );
+ $this->assertSame( '3', $captured['acme_quantity'] );
+ $this->assertSame( 'no', $captured['acme_enabled'] );
+ $this->assertSame( array( 'card', 'link' ), $captured['acme_methods'] );
+ $this->assertSame( '2026-08-03T13:45:00', $captured['acme_start'] );
+ $this->assertSame(
+ array(
+ 'quantity' => '3',
+ 'methods' => array( 'card', 'link' ),
+ 'other' => 'keep',
+ ),
+ get_option( 'acme_settings' )
+ );
+ $this->assertSame( 'no', get_option( 'acme_enabled' ) );
+ $this->assertSame( '2026-08-03T13:45:00', get_option( 'acme_start' ) );
+ $this->assertSame( maybe_serialize( get_option( 'acme_settings' ) ), $this->get_raw_option_value( 'acme_settings' ) );
+ } finally {
+ remove_filter( 'woocommerce_admin_settings_sanitize_option', $listener, 10 );
+ update_option( 'timezone_string', $original_timezone );
+ foreach ( $option_names as $option_name ) {
+ delete_option( $option_name );
+ }
+ }
+ }
+
+ /**
+ * @testdox It preserves valid native form values and accepts explicit original representations.
+ */
+ public function test_canonicalize_schema_values_preserves_native_form_representations(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_schema_values' );
+
+ $schema = SettingsUISchema::canonicalize_schema_values(
+ $this->get_native_schema_with_fields(
+ array(
+ array(
+ 'id' => 'acme_quantity',
+ 'label' => 'Quantity',
+ 'type' => 'number',
+ 'value' => '02',
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_quantity',
+ ),
+ ),
+ array(
+ 'id' => 'acme_methods',
+ 'label' => 'Methods',
+ 'type' => 'array',
+ 'value' => array( 1 ),
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => '1',
+ ),
+ ),
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_methods',
+ 'initialValue' => array( 'legacy-one' ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $fields = $schema['groups']['main']['fields'];
+ $this->assertSame( 2, $fields[0]['value'] );
+ $this->assertSame( '02', $fields[0]['save']['initialValue'] );
+ $this->assertSame( array( '1' ), $fields[1]['value'] );
+ $this->assertSame( array( 'legacy-one' ), $fields[1]['save']['initialValue'] );
+ }
+
+ /**
+ * @testdox It accepts canonical native values and extension transport values.
+ *
+ * @dataProvider settings_ui_values
+ *
+ * @param string $type Field type.
+ * @param mixed $value Field value.
+ */
+ public function test_assert_valid_schema_accepts_settings_ui_values_without_interpreting_field_type( string $type, $value ): void {
+ $field = array(
+ 'id' => 'acme_custom_field',
+ 'label' => 'Acme custom field',
+ 'type' => $type,
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'form_post' ),
+ );
+ if ( 'info' === $type ) {
+ unset( $field['value'] );
+ $field['save'] = array( 'adapter' => 'none' );
+ }
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * Settings UI transport value fixtures.
+ *
+ * @return array<string, array{string, mixed}>
+ */
+ public static function settings_ui_values(): array {
+ return array(
+ 'array' => array( 'array', array( 'a' ) ),
+ 'checkbox' => array( 'checkbox', true ),
+ 'date' => array( 'date', '2026-08-03' ),
+ 'datetime-local' => array( 'datetime-local', '2026-08-03T12:30:00+00:00' ),
+ 'email' => array( 'email', 'merchant@example.com' ),
+ 'info' => array( 'info', null ),
+ 'integer' => array( 'integer', 2 ),
+ 'number' => array( 'number', 2 ),
+ 'password' => array( 'password', 'secret' ),
+ 'radio' => array( 'radio', 'a' ),
+ 'select' => array( 'select', 'a' ),
+ 'tel' => array( 'tel', '+1 555 555 5555' ),
+ 'text' => array( 'text', 'Acme' ),
+ 'textarea' => array( 'textarea', 'Acme description' ),
+ 'time' => array( 'time', '12:30' ),
+ 'url' => array( 'url', 'https://example.com' ),
+ 'extension string' => array( 'acme/custom', 'Acme' ),
+ 'extension integer' => array( 'acme/custom', 10 ),
+ 'extension float' => array( 'acme/custom', 10.5 ),
+ 'extension boolean' => array( 'acme/custom', true ),
+ 'extension string list' => array( 'acme/custom', array( 'one', 'two' ) ),
+ 'extension null' => array( 'acme/custom', null ),
+ );
+ }
+
+ /**
+ * @testdox It accepts HTML range attributes for native temporal fields.
+ *
+ * @dataProvider native_temporal_fields_with_range_attributes
+ *
+ * @param string $type Field type.
+ * @param string $value Field value.
+ * @param array $custom_attributes HTML range attributes.
+ */
+ public function test_assert_valid_schema_accepts_range_attributes_for_native_temporal_fields( string $type, string $value, array $custom_attributes ): void {
+ $field = array(
+ 'id' => 'acme_' . $type,
+ 'label' => 'Acme ' . $type,
+ 'type' => $type,
+ 'value' => $value,
+ 'customAttributes' => $custom_attributes,
+ 'save' => array( 'adapter' => 'form_post' ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * Native temporal field fixtures with valid HTML range attributes.
+ *
+ * @return array<string, array{string, string, array<string, int|string>}>
+ */
+ public static function native_temporal_fields_with_range_attributes(): array {
+ return array(
+ 'date' => array(
+ 'date',
+ '2026-08-03',
+ array(
+ 'min' => '2026-01-01',
+ 'max' => '2026-12-31',
+ 'step' => 1,
+ ),
+ ),
+ 'time' => array(
+ 'time',
+ '12:30',
+ array(
+ 'min' => '09:00',
+ 'max' => '17:00',
+ 'step' => 900,
+ ),
+ ),
+ 'datetime-local' => array(
+ 'datetime-local',
+ '2026-08-03T12:30:00+00:00',
+ array(
+ 'min' => '2026-08-03T09:00',
+ 'max' => '2026-08-03T17:00',
+ 'step' => 'any',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox It accepts choice fields with missing or empty option lists.
+ */
+ public function test_assert_valid_schema_accepts_choice_fields_without_options(): void {
+ $fields = array(
+ array(
+ 'id' => 'acme_select_without_options',
+ 'label' => 'Select without options',
+ 'type' => 'select',
+ 'value' => '',
+ 'save' => array( 'adapter' => 'form_post' ),
+ ),
+ array(
+ 'id' => 'acme_array_with_empty_options',
+ 'label' => 'Array with empty options',
+ 'type' => 'array',
+ 'value' => array(),
+ 'options' => array(),
+ 'save' => array( 'adapter' => 'form_post' ),
+ ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_fields( $fields ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * @testdox It accepts scalar custom attributes without interpreting renderer semantics.
+ */
+ public function test_assert_valid_schema_accepts_scalar_custom_attributes_without_interpreting_renderer_semantics(): void {
+ $field = array(
+ 'id' => 'acme_custom_field',
+ 'label' => 'Acme custom field',
+ 'type' => 'acme/custom',
+ 'value' => '',
+ 'customAttributes' => array(
+ 'min' => 'extension-defined',
+ 'max' => 10,
+ 'step' => 'any',
+ 'data-enabled' => true,
+ ),
+ 'save' => array( 'adapter' => 'form_post' ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * @testdox It rejects malformed schemas with a precise boundary reason.
+ *
+ * @dataProvider invalid_schemas
+ *
+ * @param array $schema Invalid schema.
+ * @param string $reason Expected exception message.
+ */
+ public function test_assert_valid_schema_rejects_malformed_schemas( array $schema, string $reason ): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( $reason );
+
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * Invalid schema fixtures.
+ *
+ * @return array<string, array{array, string}>
+ */
+ public static function invalid_schemas(): array {
+ $valid = self::get_valid_schema_for_validation();
+
+ $empty_type = $valid;
+ $empty_type['groups']['main']['fields'][0]['type'] = '';
+
+ $duplicate_id = $valid;
+ $duplicate_id['groups']['main']['fields'][] = $duplicate_id['groups']['main']['fields'][0];
+ $group_field_collision = $valid;
+ $group_field_collision['groups']['main']['fields'][0]['id'] = 'main';
+ $empty_schema_id = $valid;
+ $empty_schema_id['id'] = '';
+ $malformed_group = $valid;
+ $malformed_group['groups']['main']['fields'] = 'invalid';
+ $invalid_options = $valid;
+ $invalid_options['groups']['main']['fields'][0]['type'] = 'select';
+ $invalid_options['groups']['main']['fields'][0]['options'] = array( 'one' => 'One' );
+ $null_options = $valid;
+ $null_options['groups']['main']['fields'][0]['type'] = 'select';
+ $null_options['groups']['main']['fields'][0]['options'] = null;
+ $invalid_option = $valid;
+ $invalid_option['groups']['main']['fields'][0]['type'] = 'select';
+ $invalid_option['groups']['main']['fields'][0]['options'] = array(
+ array(
+ 'label' => 'One',
+ 'value' => 1,
+ ),
+ );
+ $invalid_component = $valid;
+ $invalid_component['groups']['main']['fields'][0]['component'] = '';
+ $invalid_field_save = $valid;
+ $invalid_field_save['groups']['main']['fields'][0]['save'] = array( 'adapter' => 'custom' );
+ $invalid_visibility = $valid;
+ $invalid_visibility['groups']['main']['fields'][0]['visibility'] = array( 'controller' => 'missing' );
+ $invalid_bound = $valid;
+ $invalid_bound['groups']['main']['fields'][0]['customAttributes'] = array( 'min' => 1 );
+ $bad_validation = $valid;
+ $bad_validation['groups']['main']['fields'][0]['validation'] = 'invalid';
+ $text_validation = $valid;
+ $text_validation['groups']['main']['fields'][0]['validation'] = array( 'min' => 1 );
+ $bad_rule = $valid;
+ $bad_rule['groups']['main']['fields'][0]['type'] = 'number';
+ $bad_rule['groups']['main']['fields'][0]['value'] = 1;
+ $bad_rule['groups']['main']['fields'][0]['validation'] = array( 'step' => 1 );
+ $null_bound = $valid;
+ $null_bound['groups']['main']['fields'][0]['type'] = 'number';
+ $null_bound['groups']['main']['fields'][0]['value'] = 1;
+ $null_bound['groups']['main']['fields'][0]['validation'] = array( 'min' => null );
+ $infinite_bound = $valid;
+ $infinite_bound['groups']['main']['fields'][0]['type'] = 'number';
+ $infinite_bound['groups']['main']['fields'][0]['value'] = 1;
+ $infinite_bound['groups']['main']['fields'][0]['validation'] = array( 'max' => INF );
+ $fractional_bound = $valid;
+ $fractional_bound['groups']['main']['fields'][0]['type'] = 'integer';
+ $fractional_bound['groups']['main']['fields'][0]['value'] = 1;
+ $fractional_bound['groups']['main']['fields'][0]['validation'] = array( 'min' => 0.5 );
+ $invalid_field_value = $valid;
+ $invalid_field_value['groups']['main']['fields'][0]['value'] = array( 'tier' => 1 );
+ $invalid_custom_attributes = $valid;
+ $invalid_custom_attributes['groups']['main']['fields'][0]['customAttributes'] = 'invalid';
+ $invalid_custom_attribute_value = $valid;
+ $invalid_custom_attribute_value['groups']['main']['fields'][0]['customAttributes'] = array( 'data-values' => array() );
+ $invalid_custom_attribute_float = $valid;
+ $invalid_custom_attribute_float['groups']['main']['fields'][0]['customAttributes'] = array( 'data-value' => INF );
+ $invalid_info = $valid;
+ $invalid_info['groups']['main']['fields'][0]['type'] = 'info';
+ $invalid_shell = $valid;
+ $invalid_shell['shell']['navigation'] = array(
+ array(
+ 'id' => 'general',
+ 'label' => 'General',
+ ),
+ );
+ $invalid_breadcrumb = $valid;
+ $invalid_breadcrumb['shell']['breadcrumbs'] = array( array( 'label' => 1 ) );
+ $invalid_badge = $valid;
+ $invalid_badge['shell']['badges'] = array(
+ array(
'label' => 'Beta',
'intent' => array( 'invalid' ),
),
@@ -1201,10 +2982,17 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
'empty component name' => array( $invalid_component, 'Field "acme_field" component must be a non-empty string.' ),
'unsupported field save' => array( $invalid_field_save, 'Field "acme_field" save adapter must be "form_post" or "none".' ),
'missing visibility control' => array( $invalid_visibility, 'Field "acme_field" visibility controller "missing" does not reference a field.' ),
- 'invalid field value' => array( $invalid_field_value, 'Field "acme_field" value is not a valid Settings UI value.' ),
+ 'invalid field value' => array( $invalid_field_value, 'Field "acme_field" value is invalid for type "text".' ),
'invalid custom attributes' => array( $invalid_custom_attributes, 'Field "acme_field" customAttributes must be a map.' ),
'invalid custom value' => array( $invalid_custom_attribute_value, 'Field "acme_field" custom attribute "data-values" has an invalid value.' ),
'non-finite custom value' => array( $invalid_custom_attribute_float, 'Field "acme_field" custom attribute "data-value" has an invalid value.' ),
+ 'bound on text field' => array( $invalid_bound, 'Field "acme_field" may define "min" only when its type supports range attributes.' ),
+ 'invalid validation metadata' => array( $bad_validation, 'Field "acme_field" validation is supported only for numeric fields.' ),
+ 'validation on text field' => array( $text_validation, 'Field "acme_field" validation is supported only for numeric fields.' ),
+ 'unsupported validation rule' => array( $bad_rule, 'Field "acme_field" validation rule "step" must be a finite numeric bound.' ),
+ 'null validation bound' => array( $null_bound, 'Field "acme_field" validation rule "min" must be a finite numeric bound.' ),
+ 'non-finite validation bound' => array( $infinite_bound, 'Field "acme_field" validation rule "max" must be a finite numeric bound.' ),
+ 'fractional integer bound' => array( $fractional_bound, 'Field "acme_field" validation rule "min" must be an integer.' ),
'saving info field' => array( $invalid_info, 'Field "acme_field" of type "info" must use the "none" save adapter.' ),
'malformed shell navigation' => array( $invalid_shell, 'Shell navigation item 0 href must be a string.' ),
'malformed breadcrumb' => array( $invalid_breadcrumb, 'Shell breadcrumb 0 label must be a string.' ),
@@ -1337,4 +3125,139 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
),
);
}
+
+ /**
+ * Get a legacy numeric field used by save-pipeline tests.
+ *
+ * @return array
+ */
+ private function get_numeric_save_settings(): array {
+ return array(
+ array(
+ 'id' => 'acme_quantity',
+ 'title' => 'Quantity',
+ 'type' => 'number',
+ 'custom_attributes' => array( 'step' => 1 ),
+ ),
+ );
+ }
+
+ /**
+ * Build classic POST data from schema fields and optional edited values.
+ *
+ * @param array $schema Settings UI schema.
+ * @param array $edited_form_values Serialized values changed by the client, keyed by field id.
+ * @return array
+ */
+ private function get_schema_post_data( array $schema, array $edited_form_values = array() ): array {
+ $post = array();
+
+ foreach ( $schema['groups'] as $group ) {
+ foreach ( $group['fields'] as $field ) {
+ if ( 'form_post' !== ( $field['save']['adapter'] ?? null ) ) {
+ continue;
+ }
+
+ if ( array_key_exists( $field['id'], $edited_form_values ) ) {
+ $form_value = $edited_form_values[ $field['id'] ];
+ } elseif ( array_key_exists( 'initialValue', $field['save'] ) ) {
+ $form_value = $field['save']['initialValue'];
+ } else {
+ continue;
+ }
+ if ( array() === $form_value ) {
+ continue;
+ }
+
+ $name = $field['save']['name'] ?? $field['id'];
+ $base_name = '[]' === substr( $name, -2 ) ? substr( $name, 0, -2 ) : $name;
+ $open = strpos( $base_name, '[' );
+ if ( false === $open ) {
+ $post[ $base_name ] = $form_value;
+ continue;
+ }
+
+ $parent = substr( $base_name, 0, $open );
+ $member = substr( $base_name, $open + 1, -1 );
+ $post[ $parent ][ $member ] = $form_value;
+ }
+ }
+
+ return $post;
+ }
+
+ /**
+ * Save fields while capturing global and option-specific sanitizer inputs.
+ *
+ * @param array $settings Legacy settings definitions.
+ * @param array $post Schema-derived POST data.
+ * @param string $option_name Option name.
+ * @return array
+ */
+ private function save_fields_and_capture( array $settings, array $post, string $option_name ): array {
+ include_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php';
+
+ $captured = array();
+ $global = static function ( $value, $option, $raw_value ) use ( &$captured ) {
+ unset( $option );
+ $captured['global'] = array(
+ 'raw' => $raw_value,
+ 'sanitized' => $value,
+ );
+ return $value;
+ };
+ $specific = static function ( $value, $option, $raw_value ) use ( &$captured ) {
+ unset( $option );
+ $captured['specific'] = array(
+ 'raw' => $raw_value,
+ 'sanitized' => $value,
+ );
+ return $value;
+ };
+
+ add_filter( 'woocommerce_admin_settings_sanitize_option', $global, 10, 3 );
+ add_filter( 'woocommerce_admin_settings_sanitize_option_' . $option_name, $specific, 10, 3 );
+
+ try {
+ $this->assertTrue( \WC_Admin_Settings::save_fields( $settings, $post ) );
+ } finally {
+ remove_filter( 'woocommerce_admin_settings_sanitize_option', $global, 10 );
+ remove_filter( 'woocommerce_admin_settings_sanitize_option_' . $option_name, $specific, 10 );
+ }
+
+ $this->clear_option_caches( array( $option_name ) );
+
+ return $captured;
+ }
+
+ /**
+ * Clear option caches before persistence assertions.
+ *
+ * @param string[] $option_names Option names.
+ */
+ private function clear_option_caches( array $option_names ): void {
+ foreach ( $option_names as $option_name ) {
+ wp_cache_delete( $option_name, 'options' );
+ }
+
+ wp_cache_delete( 'alloptions', 'options' );
+ wp_cache_delete( 'notoptions', 'options' );
+ }
+
+ /**
+ * Read an option's raw database representation.
+ *
+ * @param string $option_name Option name.
+ * @return string|null
+ */
+ private function get_raw_option_value( string $option_name ): ?string {
+ global $wpdb;
+
+ return $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
+ $option_name
+ )
+ );
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be06443ec2a..608655b220f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2296,6 +2296,9 @@ importers:
'@wordpress/dataviews':
specifier: 17.1.0
version: 17.1.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.4.0)(@types/react@18.3.28)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1(typescript@5.7.3))
+ '@wordpress/date':
+ specifier: catalog:wp-min
+ version: 5.33.1
'@wordpress/element':
specifier: catalog:wp-min
version: 6.33.1