Commit ac3757ce09b for woocommerce

commit ac3757ce09bd1f364713e6df7985d2f682302ec9
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Tue Sep 1 10:55:43 2026 +0100

    Add a DataForm adapter for the Settings UI schema (#67846)

    * Add a DataForm adapter for the Settings UI schema

    The Settings UI renderer resolves each field to its own component and
    keeps grouping and visibility behaviour in Woo-owned page code. The
    DataForm convergence needs the schema expressed the way the package
    recommends: one field list plus a single form with groups as combined
    card fields. The schema arrives from PHP as JSON, so a JS module has to
    build the functions DataForm needs.

    Add a private dataform-adapter module that does only that mapping:

    - A descriptor table mapping every built-in field type to a DataForm
      type and edit control, with options passed through as elements.
    - Visibility rules and registered predicates mapped to isVisible, with
      a throwing predicate failing open rather than hiding the field.
    - Groups mapped to combined card form fields with isCollapsible false,
      dropped when a group predicate or every field hides them.
    - field.disabled mapped to the native isDisabled.

    The adapter deliberately takes no position on two adjacent decisions.
    It assumes the canonical value vocabulary from the PHP schema builder
    rather than coercing values per type, and it does not resolve extension
    components, since how they attach to DataForm belongs to the renderer
    wiring and its extension contract decision.

    The module is not wired into the page, so rendered output is unchanged.
    Mounted tests pin the two behaviours that were previously verified only
    at type level: package controls honour isDisabled, and grouped validity
    flows through FieldValidity.children, displayed once a control is
    touched.

    Refs WOOPRD-3595

    * Honour disabled custom attributes in the DataForm adapter

    Classic settings disable fields through custom_attributes, and the
    native renderer honoured that with HTML presence semantics. The
    adapter only read the top-level disabled key, so a field locked via
    custom attributes became editable through DataForm.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Resolve visibility predicates on every evaluation

    The adapter resolved field and group visibility predicates once, at
    build time, so a predicate registered after the adapter was built was
    silently ignored and script load order became load-bearing. The
    classic renderer resolves predicates on every evaluation.

    Resolve predicates inside the isVisible and isGroupVisible closures
    instead. Fields without visibility configuration now return an
    always-true closure so a late-registered predicate still applies. The
    lookup is a small array scan, so the per-evaluation cost is
    negligible.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Log visibility predicate failures in production

    A throwing visibility predicate fails open, rendering the field or
    group instead of hiding it, but the failure was logged through warn(),
    which is a no-op when NODE_ENV is production. A field that was meant
    to stay hidden could go live with no signal that anything went wrong.

    Route the failure through error(), which logs unconditionally, and
    cover the group-level fail-open path that previously had no test.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Raise the changelog significance to minor

    The packages/js changelog convention maps Type: add to minor, and the
    sibling add-* entries in this package use minor.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * test: unmount mounted DataForm roots in afterEach

    Tests unmounted their roots manually at the end of each case, so a
    failing assertion skipped the unmount and leaked the mounted tree into
    later tests. Tracking every mounted root and unmounting in afterEach
    keeps cleanup out of the test bodies and makes it unconditional.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * refactor: share the value equality and visibility helpers

    The adapter and the settings page each carried their own copy of
    areValuesEqual and valueMatchesVisibilityRule, so the two could drift
    apart. Moving them into a shared module keeps field visibility and
    dirty-value checks behaving the same in both places.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: map field descriptions to sanitized help elements

    Settings descriptions carry HTML, and DataForm escapes a plain string
    passed as help text, so merchants would see literal tags and lose
    links. The adapter now maps descriptions through the same sanitized
    help element the native fields already use, extracted into a shared
    helper.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: stop info fields duplicating their label in DataForm

    The info render mounted NativeSettingsField, which paints its own
    strong label, while DataForm already renders the label for read-only
    fields, so the title appeared twice. The renderer now paints only the
    sanitized description and leaves the label to DataForm, with a mounted
    test asserting the title appears once.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: map array settings fields to the select edit control

    PHP maps multiselect and multi_select_countries settings to the array
    type, which the native renderer shows as a closed multi-select. The
    adapter mapped them to the array edit control, a free-text token
    field, so merchants could type arbitrary values. The select control
    with the array type renders the closed multi-select instead.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: leave unknown field types unresolved for the renderer

    The adapter baked unknown types into read-only fields that render
    nothing, but it cannot know whether an extension will register a type
    renderer. Unknown types now keep type, Edit and render unset so the
    renderer can resolve the registry first and fail only when nothing is
    registered, matching the live page. Info fields keep the text type
    explicitly, since DataForm's regular layout skips fields whose type
    resolves no Edit control even when read-only.

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * test: cover array controller values and disabled with visibility

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * docs: note that group description chrome stays with the renderer

    Refs WOOPRD-3595

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: coerce visibility predicate results to strict booleans

    The adapter's group filter treated a field as visible unless its
    isVisible callback returned exactly false, while DataForm's layout
    hides a field on any falsy result. A third-party predicate returning
    undefined therefore kept the group in the form while the layout hid
    its only field, leaving an empty titled card. The page this adapter
    replaces uses truthiness and drops empty groups.

    Coercing the predicate result where it enters, in
    runVisibilityPredicate, makes every isVisible return the strict
    boolean its type declares, so the form filter and DataForm's layout
    can no longer disagree.

    Refs WOOPRD-3595

    * fix: keep DataForm's closed elements rule opt-in for option fields

    DataForm enables its elements validation rule by default whenever a
    field carries elements, failing any value not in the current options.
    Stored settings values routinely predate their options in WooCommerce
    (deleted page IDs, uninstalled gateways), and the plain select the
    native renderer uses never blocked saving on them.

    Setting isValid.elements to false keeps the rule opt-in, preserving
    the native behaviour until validation becomes a schema concept.

    Refs WOOPRD-3595

    * fix: map constraint custom attributes to DataForm validation rules

    Core settings pages ship min, max, pattern, minlength, maxlength and
    required through custom_attributes, and the native renderer honours
    them by spreading them onto the input. The adapter only read the
    disabled attribute, so every constraint was dropped on the way to
    DataForm.

    Map each attribute to its DataForm rule slot, keeping HTML semantics:
    presence makes required true, numeric strings coerce for number
    fields, and date constraints stay strings. Range attributes on types
    without a matching rule slot are dropped rather than guessed. step is
    not mapped because DataForm derives it from format.decimals instead
    of a rule.

    This mapping bridges the classic attribute vocabulary until
    validation becomes a schema concept (WOOPRD-3598).

    Refs WOOPRD-3595

    * fix: reuse the sanitized description in the info renderer

    Info fields sanitized the same description twice. The field already
    carries it as a sanitized element through createSettingsHelpElement,
    and DataForm never paints description for a read-only field, so that
    element was built and discarded while the renderer sanitized the
    source string again on every render.

    The renderer now shows the element the field carries, which drops the
    per-render sanitize and leaves one source for the sanitized markup.

    Refs WOOPRD-3595

    * refactor: let DataForm resolve controls from the field type

    The type table named an edit control for every settings type, but
    DataForm already derives one from the field type. Ten of the fourteen
    entries restated its default, verified by mounting each type with no
    control named: text, password, email, url, telephone, number and
    boolean all resolve the same input either way.

    Only four types still name a control, for two reasons. DataForm has no
    textarea or radio type, so those name the control the schema asked
    for. Select and array are closed lists that must not accept free text:
    DataForm infers a select only from a non-empty elements list, and
    get_options() returns an empty list when its source is unavailable, so
    an unnamed control falls back to a text input and takes any value.
    Array also defaults to a free-text token field.

    Info joins the table as a text type rather than assigning one in its
    branch, so every type resolves its control the same way.

    Refs WOOPRD-3595

    * Fix inherited settings type lookups

    * Let DataForm own option validation

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/packages/js/settings-ui/changelog/add-wooprd-3595-dataform-adapter b/packages/js/settings-ui/changelog/add-wooprd-3595-dataform-adapter
new file mode 100644
index 00000000000..78774a90108
--- /dev/null
+++ b/packages/js/settings-ui/changelog/add-wooprd-3595-dataform-adapter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a DataForm adapter that maps the Settings UI schema to a single DataForm field list and form configuration.
diff --git a/packages/js/settings-ui/src/dataform-adapter.tsx b/packages/js/settings-ui/src/dataform-adapter.tsx
new file mode 100644
index 00000000000..c992bb510f2
--- /dev/null
+++ b/packages/js/settings-ui/src/dataform-adapter.tsx
@@ -0,0 +1,348 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import type {
+	Field,
+	FieldTypeName,
+	Form,
+	FormField,
+	Rules,
+} from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { error, warn } from './diagnostics';
+import { createSettingsHelpElement } from './html';
+import {
+	resolveFieldVisibilityPredicate,
+	resolveGroupVisibilityPredicate,
+} from './registry';
+import type {
+	SettingsFieldContext,
+	SettingsUIField,
+	SettingsUIGroup,
+	SettingsUISchema,
+	SettingsValues,
+	SettingsVisibilityPredicate,
+} from './types';
+import { valueMatchesVisibilityRule } from './values';
+
+// The adapter assumes the canonical value vocabulary from the PHP schema
+// builder and how extension components attach is a renderer concern, so
+// neither value coercion nor component registry resolution happens here.
+
+export type DataFormAdapterOptions = {
+	schema: SettingsUISchema;
+	context: SettingsFieldContext;
+	initialValues: SettingsValues;
+};
+
+export type DataFormAdapter = {
+	fields: Field< SettingsValues >[];
+	getForm: ( values: SettingsValues ) => Form;
+};
+
+type SettingsTypeDescriptor = {
+	type: FieldTypeName;
+	// Only named where the type alone resolves the wrong control. DataForm
+	// derives the control from the field type otherwise, so naming one here
+	// would restate its own default.
+	edit?: string;
+};
+
+const settingsTypeDescriptors: Record< string, SettingsTypeDescriptor > = {
+	text: { type: 'text' },
+	password: { type: 'password' },
+	number: { type: 'number' },
+	checkbox: { type: 'boolean' },
+	email: { type: 'email' },
+	url: { type: 'url' },
+	tel: { type: 'telephone' },
+	date: { type: 'date' },
+	'datetime-local': { type: 'datetime' },
+	// DataForm has no time type, so the value rides in a text control.
+	time: { type: 'text' },
+	// Read-only display text. The type is here so DataForm resolves a control
+	// and keeps the field; the renderer paints the description over it.
+	info: { type: 'text' },
+	// DataForm has no textarea or radio type, so these name the control the
+	// schema asked for.
+	textarea: { type: 'text', edit: 'textarea' },
+	radio: { type: 'text', edit: 'radio' },
+	// Closed lists name their control because DataForm only infers a select
+	// from a non-empty elements list, and these types keep their meaning when
+	// the list comes back empty. Array also has to be named because DataForm
+	// defaults it to a free-text token field.
+	select: { type: 'text', edit: 'select' },
+	array: { type: 'array', edit: 'select' },
+};
+
+// Predicates fail open: a broken visibility callback renders the field or
+// group rather than hiding it. The failure logs unconditionally because
+// failing open can expose a field that was meant to stay hidden.
+const runVisibilityPredicate = (
+	predicate: SettingsVisibilityPredicate,
+	kind: 'field' | 'group',
+	id: string,
+	values: SettingsValues,
+	options: DataFormAdapterOptions
+) => {
+	try {
+		// Coerce with the truthiness DataForm's layout applies, so a loose
+		// predicate result cannot make the form filter and the layout
+		// disagree about a field.
+		return Boolean(
+			predicate( {
+				values,
+				initialValues: options.initialValues,
+				context: options.context,
+				schema: options.schema,
+			} )
+		);
+	} catch ( predicateError ) {
+		error(
+			`Visibility predicate for ${ kind } "${ id }" failed. Rendering it visible.`,
+			{ error: predicateError, context: options.context }
+		);
+		return true;
+	}
+};
+
+// Predicates resolve on every evaluation, so an extension that registers a
+// predicate after the adapter is built still takes effect.
+const createIsVisible = (
+	settingsField: SettingsUIField,
+	options: DataFormAdapterOptions
+): Field< SettingsValues >[ 'isVisible' ] => {
+	return ( item ) => {
+		const predicate = resolveFieldVisibilityPredicate(
+			settingsField.id,
+			options.context
+		);
+
+		if ( predicate ) {
+			return runVisibilityPredicate(
+				predicate,
+				'field',
+				settingsField.id,
+				item,
+				options
+			);
+		}
+
+		const visibility = settingsField.visibility;
+		return visibility
+			? valueMatchesVisibilityRule(
+					item[ visibility.controller ],
+					visibility.value
+			  )
+			: true;
+	};
+};
+
+// HTML boolean attributes use presence semantics: disabled="false" still
+// disables, while a boolean false stays unset.
+const isAttributeSet = ( value: string | number | boolean | undefined ) =>
+	typeof value !== 'undefined' && value !== false;
+
+const isFieldDisabled = ( settingsField: SettingsUIField ) =>
+	Boolean( settingsField.disabled ) ||
+	isAttributeSet( settingsField.customAttributes?.disabled );
+
+// Range constraints only validate against matching value types: numbers for
+// number fields, date strings for date fields. Other types have no range
+// rule slot in DataForm.
+const toRangeConstraint = (
+	value: string | number | boolean | undefined,
+	type: FieldTypeName | undefined
+) => {
+	if (
+		typeof value === 'boolean' ||
+		typeof value === 'undefined' ||
+		value === ''
+	) {
+		return undefined;
+	}
+
+	if ( type === 'number' ) {
+		const numeric = Number( value );
+		return Number.isFinite( numeric ) ? numeric : undefined;
+	}
+
+	if ( type === 'date' || type === 'datetime' ) {
+		return String( value );
+	}
+
+	return undefined;
+};
+
+const toLengthConstraint = ( value: string | number | boolean | undefined ) => {
+	if (
+		typeof value === 'boolean' ||
+		typeof value === 'undefined' ||
+		value === ''
+	) {
+		return undefined;
+	}
+
+	const numeric = Number( value );
+	return Number.isFinite( numeric ) ? numeric : undefined;
+};
+
+// Classic settings express constraints as HTML custom_attributes; map the
+// ones with DataForm rule slots. Step stays unmapped because DataForm derives
+// it from format.decimals rather than a rule.
+const buildValidationRules = (
+	settingsField: SettingsUIField,
+	descriptor: SettingsTypeDescriptor | undefined
+): Rules< SettingsValues > => {
+	const attributes = settingsField.customAttributes ?? {};
+	const rules: Rules< SettingsValues > = {};
+
+	if ( isAttributeSet( attributes.required ) ) {
+		rules.required = true;
+	}
+
+	const min = toRangeConstraint( attributes.min, descriptor?.type );
+	if ( typeof min !== 'undefined' ) {
+		rules.min = min;
+	}
+
+	const max = toRangeConstraint( attributes.max, descriptor?.type );
+	if ( typeof max !== 'undefined' ) {
+		rules.max = max;
+	}
+
+	const minLength = toLengthConstraint( attributes.minlength );
+	if ( typeof minLength !== 'undefined' ) {
+		rules.minLength = minLength;
+	}
+
+	const maxLength = toLengthConstraint( attributes.maxlength );
+	if ( typeof maxLength !== 'undefined' ) {
+		rules.maxLength = maxLength;
+	}
+
+	if ( typeof attributes.pattern === 'string' && attributes.pattern !== '' ) {
+		rules.pattern = attributes.pattern;
+	}
+
+	return rules;
+};
+
+export const buildDataFormField = (
+	settingsField: SettingsUIField,
+	options: DataFormAdapterOptions
+): Field< SettingsValues > => {
+	const descriptor = Object.prototype.hasOwnProperty.call(
+		settingsTypeDescriptors,
+		settingsField.type
+	)
+		? settingsTypeDescriptors[ settingsField.type ]
+		: undefined;
+
+	const field: Field< SettingsValues > = {
+		id: settingsField.id,
+		label: settingsField.label,
+		description: createSettingsHelpElement( settingsField.description ),
+		placeholder: settingsField.placeholder,
+		type: descriptor?.type,
+		elements: settingsField.options,
+		isValid: buildValidationRules( settingsField, descriptor ),
+		isVisible: createIsVisible( settingsField, options ),
+		isDisabled: isFieldDisabled( settingsField ),
+	};
+
+	if ( settingsField.type === 'info' ) {
+		field.readOnly = true;
+		// DataForm paints the label for a read-only field and drops its
+		// description, so info shows the sanitized element the field already
+		// carries rather than sanitizing the same string again per render.
+		field.render = ( { field: normalizedField } ) =>
+			normalizedField.description ? (
+				<div className="wc-settings-ui__info">
+					{ normalizedField.description }
+				</div>
+			) : null;
+		return field;
+	}
+
+	if ( ! descriptor ) {
+		// The renderer resolves registered type renderers before failing, so
+		// unknown types keep Edit and render unset rather than a baked
+		// fallback the adapter cannot decide on.
+		warn( `Field type "${ settingsField.type }" is not supported.`, {
+			field: settingsField,
+		} );
+		return field;
+	}
+
+	if ( descriptor.edit ) {
+		field.Edit = descriptor.edit;
+	}
+
+	return field;
+};
+
+// Group descriptions and actions are HTML chrome that stays with the
+// renderer; FormField.description only accepts a plain string.
+const buildGroupFormField = ( group: SettingsUIGroup ): FormField => ( {
+	id: group.id,
+	label: group.title || undefined,
+	layout: group.title
+		? { type: 'card', isCollapsible: false }
+		: { type: 'card', withHeader: false },
+	children: group.fields.map( ( field ) => field.id ),
+} );
+
+export const createDataFormAdapter = (
+	options: DataFormAdapterOptions
+): DataFormAdapter => {
+	const groups = Object.values( options.schema.groups );
+	const fields = groups.flatMap( ( group ) =>
+		group.fields.map( ( field ) => buildDataFormField( field, options ) )
+	);
+	const fieldsById = new Map(
+		fields.map( ( field ) => [ field.id, field ] )
+	);
+
+	// A field without an isVisible callback is shown, matching DataForm.
+	const isFieldVisible = ( fieldId: string, values: SettingsValues ) =>
+		Boolean( fieldsById.get( fieldId )?.isVisible?.( values ) ?? true );
+
+	const isGroupVisible = (
+		group: SettingsUIGroup,
+		values: SettingsValues
+	) => {
+		const predicate = resolveGroupVisibilityPredicate(
+			group.id,
+			options.context
+		);
+		if (
+			predicate &&
+			! runVisibilityPredicate(
+				predicate,
+				'group',
+				group.id,
+				values,
+				options
+			)
+		) {
+			return false;
+		}
+
+		return group.fields.some( ( field ) =>
+			isFieldVisible( field.id, values )
+		);
+	};
+
+	const getForm = ( values: SettingsValues ): Form => ( {
+		fields: groups
+			.filter( ( group ) => isGroupVisible( group, values ) )
+			.map( buildGroupFormField ),
+	} );
+
+	return { fields, getForm };
+};
diff --git a/packages/js/settings-ui/src/html.ts b/packages/js/settings-ui/src/html.ts
index 5c915304b86..3d838b9c4fe 100644
--- a/packages/js/settings-ui/src/html.ts
+++ b/packages/js/settings-ui/src/html.ts
@@ -2,5 +2,17 @@
  * External dependencies
  */
 import { sanitizeHTML } from '@woocommerce/sanitize';
+import { createElement } from '@wordpress/element';

 export const sanitizeSettingsHtml = ( html?: string ) => sanitizeHTML( html );
+
+// Settings descriptions carry HTML, so help text has to reach controls as a
+// sanitized element; a plain string would be escaped and shown verbatim.
+export const createSettingsHelpElement = ( html?: string ) =>
+	html
+		? createElement( 'span', {
+				dangerouslySetInnerHTML: {
+					__html: sanitizeSettingsHtml( html ),
+				},
+		  } )
+		: undefined;
diff --git a/packages/js/settings-ui/src/native-fields.tsx b/packages/js/settings-ui/src/native-fields.tsx
index 945a7c9688e..6af32ac3449 100644
--- a/packages/js/settings-ui/src/native-fields.tsx
+++ b/packages/js/settings-ui/src/native-fields.tsx
@@ -15,7 +15,10 @@ import { __ } from '@wordpress/i18n';
  * Internal dependencies
  */
 import { warn } from './diagnostics';
-import { sanitizeSettingsHtml } from './html';
+import {
+	createSettingsHelpElement as getHelp,
+	sanitizeSettingsHtml,
+} from './html';
 import { NumberSpinControl } from './number-spin-control';
 import type { SettingsFieldComponentProps, SettingsValue } from './types';

@@ -95,15 +98,6 @@ const getNumberInputAttributes = (
 	};
 };

-const getHelp = ( description?: string ) =>
-	description ? (
-		<span
-			dangerouslySetInnerHTML={ {
-				__html: sanitizeSettingsHtml( description ),
-			} }
-		/>
-	) : undefined;
-
 export const NativeSettingsField = ( {
 	field,
 	value,
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index 3a2023d1669..a9e3e06c581 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -43,6 +43,7 @@ import type {
 	SettingsValue,
 	SettingsValues,
 } from './types';
+import { areValuesEqual, valueMatchesVisibilityRule } from './values';

 type SaveNotice = {
 	status: 'success' | 'error';
@@ -71,19 +72,6 @@ const getInitialValues = ( schema: SettingsUISchema ): SettingsValues => {
 	return values;
 };

-const areValuesEqual = ( a: SettingsValue, b: SettingsValue ) => {
-	if ( Array.isArray( a ) || Array.isArray( b ) ) {
-		return (
-			Array.isArray( a ) &&
-			Array.isArray( b ) &&
-			a.length === b.length &&
-			a.every( ( value, index ) => value === b[ index ] )
-		);
-	}
-
-	return a === b;
-};
-
 const getChangedValues = (
 	values: SettingsValues,
 	initialValues: SettingsValues
@@ -275,19 +263,6 @@ const GroupHeader = ( { group }: { group: SettingsUIGroup } ) => {
 	);
 };

-const valueMatchesVisibilityRule = (
-	value: SettingsValue,
-	expected: SettingsValue | SettingsValue[] | undefined
-) => {
-	const expectedValues = Array.isArray( expected )
-		? expected
-		: [ expected ?? true ];
-
-	return expectedValues.some( ( expectedValue ) =>
-		areValuesEqual( value, expectedValue )
-	);
-};
-
 const getVisible = ( {
 	id,
 	kind,
diff --git a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
new file mode 100644
index 00000000000..532eba63607
--- /dev/null
+++ b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
@@ -0,0 +1,851 @@
+/**
+ * External dependencies
+ */
+import { DataForm } from '@wordpress/dataviews';
+import { act } from 'react';
+import { createElement } from '@wordpress/element';
+import { createRoot } from 'react-dom/client';
+
+/**
+ * Internal dependencies
+ */
+import { buildDataFormField, createDataFormAdapter } from '../dataform-adapter';
+import type { DataFormAdapterOptions } from '../dataform-adapter';
+import { __resetRegistry, registerSettingsExtension } from '../registry';
+import type {
+	SettingsUIField,
+	SettingsUISchema,
+	SettingsValues,
+	SettingsVisibilityPredicate,
+} from '../types';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const context = { page: 'test-page', section: 'default' };
+
+const createSchema = ( fields: SettingsUIField[] ): SettingsUISchema => ( {
+	id: 'test-page',
+	title: 'Test page',
+	section: 'default',
+	save: { adapter: 'none' },
+	groups: {
+		general: {
+			id: 'general',
+			title: 'General',
+			fields,
+		},
+	},
+} );
+
+const createOptions = (
+	fields: SettingsUIField[],
+	initialValues: SettingsValues = {}
+): DataFormAdapterOptions => ( {
+	schema: createSchema( fields ),
+	context,
+	initialValues,
+} );
+
+const textField: SettingsUIField = {
+	id: 'test_field',
+	label: 'Test field',
+	type: 'text',
+};
+
+const mountedRoots: Array< () => void > = [];
+
+const renderElement = ( element: JSX.Element ) => {
+	const container = document.createElement( 'div' );
+	document.body.appendChild( container );
+	const root = createRoot( container );
+	act( () => {
+		root.render( element );
+	} );
+	mountedRoots.push( () => {
+		act( () => root.unmount() );
+		container.remove();
+	} );
+	return { container };
+};
+
+describe( 'dataform adapter', () => {
+	afterEach( () => {
+		while ( mountedRoots.length > 0 ) {
+			mountedRoots.pop()?.();
+		}
+		__resetRegistry();
+		jest.restoreAllMocks();
+	} );
+
+	describe( 'field type mapping', () => {
+		const typeExpectations: Array< [ string, string ] > = [
+			[ 'text', 'text' ],
+			[ 'password', 'password' ],
+			[ 'datetime-local', 'datetime' ],
+			[ 'date', 'date' ],
+			[ 'time', 'text' ],
+			[ 'info', 'text' ],
+			[ 'email', 'email' ],
+			[ 'url', 'url' ],
+			[ 'tel', 'telephone' ],
+			[ 'textarea', 'text' ],
+			[ 'select', 'text' ],
+			[ 'radio', 'text' ],
+			[ 'checkbox', 'boolean' ],
+			[ 'number', 'number' ],
+			[ 'array', 'array' ],
+		];
+
+		it.each( typeExpectations )(
+			'maps the "%s" settings type to DataForm type "%s"',
+			( settingsType, dataFormType ) => {
+				const field = buildDataFormField(
+					{ ...textField, type: settingsType },
+					createOptions( [ { ...textField, type: settingsType } ] )
+				);
+
+				expect( field.type ).toBe( dataFormType );
+			}
+		);
+
+		// Naming a control DataForm already derives from the type would
+		// restate its default, so only these types name one.
+		it.each( [
+			[ 'textarea', 'textarea' ],
+			[ 'radio', 'radio' ],
+			[ 'select', 'select' ],
+			[ 'array', 'select' ],
+		] )(
+			'names the "%s" control for the "%s" type',
+			( settingsType, editControl ) => {
+				const field = buildDataFormField(
+					{ ...textField, type: settingsType },
+					createOptions( [] )
+				);
+
+				expect( field.Edit ).toBe( editControl );
+			}
+		);
+
+		it.each( [
+			'text',
+			'password',
+			'datetime-local',
+			'date',
+			'time',
+			'email',
+			'url',
+			'tel',
+			'checkbox',
+			'number',
+		] )( 'leaves the "%s" control to DataForm', ( settingsType ) => {
+			const field = buildDataFormField(
+				{ ...textField, type: settingsType },
+				createOptions( [] )
+			);
+
+			expect( field.Edit ).toBeUndefined();
+		} );
+
+		it( 'passes options through as elements', () => {
+			const options = [
+				{ label: 'One', value: 'one' },
+				{ label: 'Two', value: 'two' },
+			];
+			const field = buildDataFormField(
+				{ ...textField, type: 'select', options },
+				createOptions( [] )
+			);
+
+			expect( field.elements ).toEqual( options );
+		} );
+
+		it( 'renders info fields read-only with only the sanitized description', () => {
+			const infoField: SettingsUIField = {
+				id: 'info_field',
+				label: 'Read this',
+				type: 'info',
+				description:
+					'Useful <strong>information</strong>.<script>alert(1)</script>',
+			};
+			const field = buildDataFormField(
+				infoField,
+				createOptions( [ infoField ] )
+			);
+
+			expect( field.readOnly ).toBe( true );
+			expect( field.Edit ).toBeUndefined();
+
+			const Render = field.render as ( props: {
+				item: SettingsValues;
+				field: typeof field;
+			} ) => JSX.Element;
+			const { container } = renderElement(
+				<Render item={ {} } field={ field } />
+			);
+			expect(
+				container.querySelector( '.wc-settings-ui__info' )
+			).not.toBeNull();
+			expect( container.querySelector( 'script' ) ).toBeNull();
+			// DataForm owns the label for read-only fields, so the render
+			// contributes the description and nothing else.
+			expect( container.textContent ).toBe( 'Useful information.' );
+		} );
+
+		it( 'maps field descriptions to sanitized help elements', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					description:
+						'See the <a href="https://woocommerce.com">docs</a>.',
+				},
+				createOptions( [] )
+			);
+
+			const { container } = renderElement( <>{ field.description }</> );
+			const link = container.querySelector( 'a' );
+			expect( link?.textContent ).toBe( 'docs' );
+			expect( container.textContent ).toBe( 'See the docs.' );
+		} );
+
+		it.each( [
+			'extension_defined',
+			'constructor',
+			'__proto__',
+			'toString',
+		] )(
+			'warns and leaves the control unset for unknown type "%s"',
+			( type ) => {
+				const warnSpy = jest
+					.spyOn( console, 'warn' )
+					.mockImplementation( () => undefined );
+				const field = buildDataFormField(
+					{ ...textField, type },
+					createOptions( [] )
+				);
+
+				expect( field.type ).toBeUndefined();
+				expect( field.Edit ).toBeUndefined();
+				expect( field.render ).toBeUndefined();
+				expect( field.readOnly ).toBeUndefined();
+				expect( warnSpy ).toHaveBeenCalledWith(
+					expect.stringContaining(
+						`Field type "${ type }" is not supported.`
+					),
+					expect.any( Object )
+				);
+			}
+		);
+	} );
+
+	describe( 'visibility', () => {
+		it( 'maps a visibility rule to isVisible with single and list values', () => {
+			const single = buildDataFormField(
+				{
+					...textField,
+					visibility: { controller: 'toggle', value: 'on' },
+				},
+				createOptions( [] )
+			);
+			expect( single.isVisible?.( { toggle: 'on' } ) ).toBe( true );
+			expect( single.isVisible?.( { toggle: 'off' } ) ).toBe( false );
+
+			const list = buildDataFormField(
+				{
+					...textField,
+					visibility: { controller: 'toggle', value: [ 'a', 'b' ] },
+				},
+				createOptions( [] )
+			);
+			expect( list.isVisible?.( { toggle: 'b' } ) ).toBe( true );
+			expect( list.isVisible?.( { toggle: 'c' } ) ).toBe( false );
+
+			const defaulted = buildDataFormField(
+				{ ...textField, visibility: { controller: 'toggle' } },
+				createOptions( [] )
+			);
+			expect( defaulted.isVisible?.( { toggle: true } ) ).toBe( true );
+			expect( defaulted.isVisible?.( { toggle: false } ) ).toBe( false );
+		} );
+
+		it( 'prefers a registered predicate over the schema rule', () => {
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				fieldVisibility: {
+					test_field: ( { values } ) => values.other === 'show',
+				},
+			} );
+			const field = buildDataFormField(
+				{
+					...textField,
+					visibility: { controller: 'toggle', value: 'on' },
+				},
+				createOptions( [] )
+			);
+
+			expect(
+				field.isVisible?.( { toggle: 'off', other: 'show' } )
+			).toBe( true );
+			expect( field.isVisible?.( { toggle: 'on', other: 'hide' } ) ).toBe(
+				false
+			);
+		} );
+
+		it( 'fails open and logs an error when a predicate throws', () => {
+			const errorSpy = jest
+				.spyOn( console, 'error' )
+				.mockImplementation( () => undefined );
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				fieldVisibility: {
+					test_field: () => {
+						throw new Error( 'broken predicate' );
+					},
+				},
+			} );
+			const field = buildDataFormField(
+				textField,
+				createOptions( [ textField ] )
+			);
+
+			expect( field.isVisible?.( {} ) ).toBe( true );
+			expect( errorSpy ).toHaveBeenCalledWith(
+				expect.stringContaining(
+					'Visibility predicate for field "test_field" failed.'
+				),
+				expect.any( Object )
+			);
+		} );
+
+		it( 'compares array controller values by element equality', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					visibility: {
+						controller: 'regions',
+						value: [ [ 'eu', 'us' ] ],
+					},
+				},
+				createOptions( [] )
+			);
+
+			expect( field.isVisible?.( { regions: [ 'eu', 'us' ] } ) ).toBe(
+				true
+			);
+			expect( field.isVisible?.( { regions: [ 'eu' ] } ) ).toBe( false );
+			expect( field.isVisible?.( { regions: [ 'us', 'eu' ] } ) ).toBe(
+				false
+			);
+		} );
+
+		it( 'keeps disabled and visibility independent on the same field', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					disabled: true,
+					visibility: { controller: 'toggle', value: 'on' },
+				},
+				createOptions( [] )
+			);
+
+			expect( field.isDisabled ).toBe( true );
+			expect( field.isVisible?.( { toggle: 'off' } ) ).toBe( false );
+			expect( field.isVisible?.( { toggle: 'on' } ) ).toBe( true );
+		} );
+
+		it( 'coerces loose predicate results to strict booleans', () => {
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				fieldVisibility: {
+					// Third-party predicates are not held to the typed
+					// boolean return.
+					test_field: ( ( { values }: { values: SettingsValues } ) =>
+						values.other === 'show' ||
+						undefined ) as SettingsVisibilityPredicate,
+				},
+			} );
+			const field = buildDataFormField(
+				textField,
+				createOptions( [ textField ] )
+			);
+
+			expect( field.isVisible?.( { other: 'show' } ) ).toBe( true );
+			expect( field.isVisible?.( { other: 'hide' } ) ).toBe( false );
+		} );
+
+		it( 'applies a predicate registered after the field is built', () => {
+			const field = buildDataFormField(
+				textField,
+				createOptions( [ textField ] )
+			);
+			expect( field.isVisible?.( { other: 'hide' } ) ).toBe( true );
+
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				fieldVisibility: {
+					test_field: ( { values } ) => values.other === 'show',
+				},
+			} );
+
+			expect( field.isVisible?.( { other: 'hide' } ) ).toBe( false );
+			expect( field.isVisible?.( { other: 'show' } ) ).toBe( true );
+		} );
+	} );
+
+	describe( 'form configuration', () => {
+		it( 'maps groups to combined card fields', () => {
+			const schema: SettingsUISchema = {
+				id: 'test-page',
+				groups: {
+					titled: {
+						id: 'titled',
+						title: 'Titled group',
+						fields: [ textField ],
+					},
+					untitled: {
+						id: 'untitled',
+						fields: [
+							{ id: 'other_field', label: 'Other', type: 'text' },
+						],
+					},
+				},
+			};
+			const adapter = createDataFormAdapter( {
+				schema,
+				context,
+				initialValues: {},
+			} );
+			const form = adapter.getForm( {} );
+
+			expect( form.fields ).toEqual( [
+				{
+					id: 'titled',
+					label: 'Titled group',
+					layout: { type: 'card', isCollapsible: false },
+					children: [ 'test_field' ],
+				},
+				{
+					id: 'untitled',
+					label: undefined,
+					layout: { type: 'card', withHeader: false },
+					children: [ 'other_field' ],
+				},
+			] );
+		} );
+
+		it( 'drops a group when its predicate hides it or all fields are hidden', () => {
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				groupVisibility: {
+					by_predicate: ( { values } ) => values.show === 'yes',
+				},
+			} );
+			const schema: SettingsUISchema = {
+				id: 'test-page',
+				groups: {
+					by_predicate: {
+						id: 'by_predicate',
+						fields: [ textField ],
+					},
+					by_fields: {
+						id: 'by_fields',
+						fields: [
+							{
+								id: 'hidden_field',
+								label: 'Hidden',
+								type: 'text',
+								visibility: {
+									controller: 'toggle',
+									value: 'on',
+								},
+							},
+						],
+					},
+				},
+			};
+			const adapter = createDataFormAdapter( {
+				schema,
+				context,
+				initialValues: {},
+			} );
+
+			const visible = adapter.getForm( { show: 'yes', toggle: 'on' } );
+			expect(
+				visible.fields?.map( ( f ) => ( f as { id: string } ).id )
+			).toEqual( [ 'by_predicate', 'by_fields' ] );
+
+			const hidden = adapter.getForm( { show: 'no', toggle: 'off' } );
+			expect( hidden.fields ).toEqual( [] );
+		} );
+
+		it( 'drops a group whose only field is hidden by a loose predicate', () => {
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				fieldVisibility: {
+					test_field: ( () =>
+						undefined ) as unknown as SettingsVisibilityPredicate,
+				},
+			} );
+			const adapter = createDataFormAdapter(
+				createOptions( [ textField ] )
+			);
+
+			// DataForm's layout hides the field, so keeping the group would
+			// leave an empty titled card behind.
+			expect( adapter.getForm( {} ).fields ).toEqual( [] );
+		} );
+
+		it( 'fails open and logs an error when a group predicate throws', () => {
+			const errorSpy = jest
+				.spyOn( console, 'error' )
+				.mockImplementation( () => undefined );
+			const adapter = createDataFormAdapter( {
+				schema: createSchema( [ textField ] ),
+				context,
+				initialValues: {},
+			} );
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				groupVisibility: {
+					general: () => {
+						throw new Error( 'broken predicate' );
+					},
+				},
+			} );
+
+			const form = adapter.getForm( {} );
+			expect(
+				form.fields?.map( ( f ) => ( f as { id: string } ).id )
+			).toEqual( [ 'general' ] );
+			expect( errorSpy ).toHaveBeenCalledWith(
+				expect.stringContaining(
+					'Visibility predicate for group "general" failed.'
+				),
+				expect.any( Object )
+			);
+		} );
+	} );
+
+	describe( 'validation rules', () => {
+		it( 'leaves closed elements validation to DataForm', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					type: 'select',
+					options: [
+						{ label: 'One', value: 'one' },
+						{ label: 'Two', value: 'two' },
+					],
+				},
+				createOptions( [] )
+			);
+
+			expect( field.isValid?.elements ).toBeUndefined();
+		} );
+
+		it( 'maps number range attributes to numeric constraints', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					type: 'number',
+					customAttributes: { min: '0', max: 100 },
+				},
+				createOptions( [] )
+			);
+
+			expect( field.isValid?.min ).toBe( 0 );
+			expect( field.isValid?.max ).toBe( 100 );
+		} );
+
+		it( 'maps date range attributes as strings', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					type: 'date',
+					customAttributes: { min: '2026-01-01', max: '2026-12-31' },
+				},
+				createOptions( [] )
+			);
+
+			expect( field.isValid?.min ).toBe( '2026-01-01' );
+			expect( field.isValid?.max ).toBe( '2026-12-31' );
+		} );
+
+		it( 'ignores range attributes on types without a range rule', () => {
+			const field = buildDataFormField(
+				{ ...textField, customAttributes: { min: '5', max: '10' } },
+				createOptions( [] )
+			);
+
+			expect( field.isValid?.min ).toBeUndefined();
+			expect( field.isValid?.max ).toBeUndefined();
+		} );
+
+		it( 'maps length and pattern attributes', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					customAttributes: {
+						minlength: 2,
+						maxlength: '10',
+						pattern: '[a-z]+',
+					},
+				},
+				createOptions( [] )
+			);
+
+			expect( field.isValid?.minLength ).toBe( 2 );
+			expect( field.isValid?.maxLength ).toBe( 10 );
+			expect( field.isValid?.pattern ).toBe( '[a-z]+' );
+		} );
+
+		it( 'honours a required attribute with presence semantics', () => {
+			const present = buildDataFormField(
+				{ ...textField, customAttributes: { required: 'required' } },
+				createOptions( [] )
+			);
+			expect( present.isValid?.required ).toBe( true );
+
+			const booleanFalse = buildDataFormField(
+				{ ...textField, customAttributes: { required: false } },
+				createOptions( [] )
+			);
+			expect( booleanFalse.isValid?.required ).toBeUndefined();
+
+			const absent = buildDataFormField( textField, createOptions( [] ) );
+			expect( absent.isValid?.required ).toBeUndefined();
+		} );
+	} );
+
+	describe( 'disabled state', () => {
+		it( 'honours a disabled custom attribute with presence semantics', () => {
+			const attributeDisabled = buildDataFormField(
+				{ ...textField, customAttributes: { disabled: 'disabled' } },
+				createOptions( [] )
+			);
+			expect( attributeDisabled.isDisabled ).toBe( true );
+
+			const attributeFalse = buildDataFormField(
+				{ ...textField, customAttributes: { disabled: false } },
+				createOptions( [] )
+			);
+			expect( attributeFalse.isDisabled ).toBe( false );
+
+			const noAttribute = buildDataFormField(
+				textField,
+				createOptions( [] )
+			);
+			expect( noAttribute.isDisabled ).toBe( false );
+
+			const fieldDisabled = buildDataFormField(
+				{ ...textField, disabled: true },
+				createOptions( [] )
+			);
+			expect( fieldDisabled.isDisabled ).toBe( true );
+		} );
+	} );
+
+	describe( 'mounted DataForm behaviour', () => {
+		it( 'honours isDisabled on package controls', () => {
+			const enabledField: SettingsUIField = {
+				id: 'enabled_field',
+				label: 'Enabled field',
+				type: 'text',
+			};
+			const disabledField: SettingsUIField = {
+				id: 'disabled_field',
+				label: 'Disabled field',
+				type: 'text',
+				disabled: true,
+			};
+			const options = createOptions( [ enabledField, disabledField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { enabled_field: 'a', disabled_field: 'b' };
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+				/>
+			);
+
+			const inputs = Array.from( container.querySelectorAll( 'input' ) );
+			expect( inputs.length ).toBe( 2 );
+			expect(
+				inputs.some(
+					( input ) => input.value === 'a' && ! input.disabled
+				)
+			).toBe( true );
+			expect(
+				inputs.some(
+					( input ) => input.value === 'b' && input.disabled
+				)
+			).toBe( true );
+		} );
+
+		it( 'shows an info field title exactly once', () => {
+			const infoField: SettingsUIField = {
+				id: 'info_field',
+				label: 'Read this',
+				type: 'info',
+				description: 'Useful <strong>information</strong>.',
+			};
+			const options = createOptions( [ infoField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = {};
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+				/>
+			);
+
+			expect( container.textContent?.match( /Read this/g ) ).toHaveLength(
+				1
+			);
+			expect( container.textContent ).toContain( 'Useful information.' );
+		} );
+
+		it.each( [
+			[ 'text', 'text' ],
+			[ 'password', 'password' ],
+			[ 'email', 'email' ],
+			[ 'url', 'url' ],
+			[ 'tel', 'tel' ],
+			[ 'number', 'number' ],
+			[ 'checkbox', 'checkbox' ],
+		] )(
+			'lets DataForm resolve the "%s" type to input[type=%s]',
+			( settingsType, inputType ) => {
+				const field: SettingsUIField = {
+					id: 'probe_field',
+					label: 'Probe field',
+					type: settingsType,
+				};
+				const options = createOptions( [ field ] );
+				const adapter = createDataFormAdapter( options );
+				const data = { probe_field: '' };
+
+				const { container } = renderElement(
+					<DataForm
+						data={ data }
+						fields={ adapter.fields }
+						form={ adapter.getForm( data ) }
+						onChange={ () => undefined }
+					/>
+				);
+
+				expect( container.querySelector( 'input' )?.type ).toBe(
+					inputType
+				);
+			}
+		);
+
+		it( 'never degrades a closed list to free text when options are empty', () => {
+			// get_options() returns an empty list when its source is
+			// unavailable. DataForm infers a select only from a non-empty
+			// list, so leaving the control unnamed would fall back to a text
+			// input and accept any value for a closed choice.
+			const emptySelect: SettingsUIField = {
+				id: 'country',
+				label: 'Country',
+				type: 'select',
+				options: [],
+			};
+			const options = createOptions( [ emptySelect ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { country: '' };
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+				/>
+			);
+
+			expect( container.querySelector( 'input' ) ).toBeNull();
+			expect( container.querySelector( 'textarea' ) ).toBeNull();
+		} );
+
+		it( 'renders array fields as a closed multi-select', () => {
+			const arrayField: SettingsUIField = {
+				id: 'countries',
+				label: 'Countries',
+				type: 'array',
+				options: [
+					{ label: 'France', value: 'FR' },
+					{ label: 'Spain', value: 'ES' },
+				],
+			};
+			const options = createOptions( [ arrayField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { countries: [ 'FR' ] };
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+				/>
+			);
+
+			const select = container.querySelector( 'select' );
+			expect( select?.multiple ).toBe( true );
+			expect(
+				Array.from( select?.options ?? [] ).map( ( o ) => o.value )
+			).toEqual( [ 'FR', 'ES' ] );
+			// A closed control offers no free-text input.
+			expect( container.querySelector( 'input' ) ).toBeNull();
+		} );
+
+		it( 'surfaces grouped validity through FieldValidity children', () => {
+			const options = createOptions( [ textField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { test_field: 'value' };
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+					validity={ {
+						general: {
+							children: {
+								test_field: {
+									custom: {
+										type: 'invalid',
+										message: 'This value is not allowed.',
+									},
+								},
+							},
+						},
+					} }
+				/>
+			);
+
+			// The controls defer validation display until they are touched.
+			expect( container.textContent ).not.toContain(
+				'This value is not allowed.'
+			);
+
+			const input = container.querySelector( 'input' );
+			act( () => {
+				input?.focus();
+				input?.blur();
+			} );
+
+			expect( container.textContent ).toContain(
+				'This value is not allowed.'
+			);
+		} );
+	} );
+} );
diff --git a/packages/js/settings-ui/src/values.ts b/packages/js/settings-ui/src/values.ts
new file mode 100644
index 00000000000..91180490af5
--- /dev/null
+++ b/packages/js/settings-ui/src/values.ts
@@ -0,0 +1,30 @@
+/**
+ * Internal dependencies
+ */
+import type { SettingsValue } from './types';
+
+export const areValuesEqual = ( a: SettingsValue, b: SettingsValue ) => {
+	if ( Array.isArray( a ) || Array.isArray( b ) ) {
+		return (
+			Array.isArray( a ) &&
+			Array.isArray( b ) &&
+			a.length === b.length &&
+			a.every( ( value, index ) => value === b[ index ] )
+		);
+	}
+
+	return a === b;
+};
+
+export const valueMatchesVisibilityRule = (
+	value: SettingsValue,
+	expected: SettingsValue | SettingsValue[] | undefined
+) => {
+	const expectedValues = Array.isArray( expected )
+		? expected
+		: [ expected ?? true ];
+
+	return expectedValues.some( ( expectedValue ) =>
+		areValuesEqual( value, expectedValue )
+	);
+};